Leaderboard Inertia'd + use cache + util dedup (#1121)

* make leaderboards go vrooom

* goog

* Make leaderboards great again

* Bit o' cleanup?

* goog

* goog

* Greptile
This commit is contained in:
Mahad Kalam 2026-03-30 15:39:05 +01:00 committed by GitHub
parent 8ce245d8c4
commit 28fe4739f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 811 additions and 442 deletions

View file

@ -1,31 +1,31 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action_text-trix (2.1.17) action_text-trix (2.1.18)
railties railties
actioncable (8.1.2) actioncable (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.1.2) actionmailbox (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activestorage (= 8.1.2) activestorage (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.1.2) actionmailer (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
actionview (= 8.1.2) actionview (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.1.2) actionpack (8.1.3)
actionview (= 8.1.2) actionview (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@ -33,38 +33,38 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.1.2) actiontext (8.1.3)
action_text-trix (~> 2.1.15) action_text-trix (~> 2.1.15)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activestorage (= 8.1.2) activestorage (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.1.2) actionview (8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.1.2) activejob (8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.1.2) activemodel (8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
activerecord (8.1.2) activerecord (8.1.3)
activemodel (= 8.1.2) activemodel (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activerecord-import (2.2.0) activerecord-import (2.2.0)
activerecord (>= 4.2) activerecord (>= 4.2)
activestorage (8.1.2) activestorage (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.1.2) activesupport (8.1.3)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
@ -82,7 +82,7 @@ GEM
ast (2.4.3) ast (2.4.3)
autotuner (1.1.0) autotuner (1.1.0)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
aws-partitions (1.1229.0) aws-partitions (1.1231.0)
aws-sdk-core (3.244.0) aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
@ -109,7 +109,7 @@ GEM
erubi (~> 1.4) erubi (~> 1.4)
parser (>= 2.4) parser (>= 2.4)
smart_properties smart_properties
bigdecimal (4.0.1) bigdecimal (4.1.0)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.23.0) bootsnap (1.23.0)
msgpack (~> 1.2) msgpack (~> 1.2)
@ -189,22 +189,22 @@ GEM
faraday-net_http_persistent (2.3.1) faraday-net_http_persistent (2.3.1)
faraday (~> 2.5) faraday (~> 2.5)
net-http-persistent (>= 4.0.4, < 5) net-http-persistent (>= 4.0.4, < 5)
ffi (1.17.3-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl) ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu) ffi (1.17.4-arm-linux-gnu)
ffi (1.17.3-arm-linux-musl) ffi (1.17.4-arm-linux-musl)
ffi (1.17.3-arm64-darwin) ffi (1.17.4-arm64-darwin)
ffi (1.17.3-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl) ffi (1.17.4-x86_64-linux-musl)
flamegraph (0.9.5) flamegraph (0.9.5)
flipper (1.4.0) flipper (1.4.1)
concurrent-ruby (< 2) concurrent-ruby (< 2)
flipper-active_record (1.4.0) flipper-active_record (1.4.1)
activerecord (>= 4.2, < 9) activerecord (>= 4.2, < 9)
flipper (~> 1.4.0) flipper (~> 1.4.1)
flipper-ui (1.4.0) flipper-ui (1.4.1)
erubi (>= 1.0.0, < 2.0.0) erubi (>= 1.0.0, < 2.0.0)
flipper (~> 1.4.0) flipper (~> 1.4.1)
rack (>= 1.4, < 4) rack (>= 1.4, < 4)
rack-protection (>= 1.5.3, < 5.0.0) rack-protection (>= 1.5.3, < 5.0.0)
rack-session (>= 1.0.2, < 3.0.0) rack-session (>= 1.0.2, < 3.0.0)
@ -245,7 +245,7 @@ GEM
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
inertia_rails (3.18.0) inertia_rails (3.19.0)
railties (>= 6) railties (>= 6)
io-console (0.8.2) io-console (0.8.2)
irb (1.17.0) irb (1.17.0)
@ -257,7 +257,7 @@ GEM
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.19.2) json (2.19.3)
json-schema (6.2.0) json-schema (6.2.0)
addressable (~> 2.8) addressable (~> 2.8)
bigdecimal (>= 3.1, < 5) bigdecimal (>= 3.1, < 5)
@ -300,8 +300,6 @@ GEM
activesupport (>= 7.1) activesupport (>= 7.1)
marcel (1.1.0) marcel (1.1.0)
matrix (0.4.3) matrix (0.4.3)
mcp (0.9.0)
json-schema (>= 4.1)
memory_profiler (1.1.0) memory_profiler (1.1.0)
mini_magick (5.3.1) mini_magick (5.3.1)
logger logger
@ -357,7 +355,7 @@ GEM
activerecord (>= 7.1) activerecord (>= 7.1)
request_store (~> 1.4) request_store (~> 1.4)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.10.2) parser (3.3.11.1)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pg (1.6.3) pg (1.6.3)
@ -366,7 +364,7 @@ GEM
pg (1.6.3-arm64-darwin) pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-linux) pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl) pg (1.6.3-x86_64-linux-musl)
posthog-ruby (3.6.0) posthog-ruby (3.6.1)
concurrent-ruby (~> 1) concurrent-ruby (~> 1)
pp (0.6.3) pp (0.6.3)
prettyprint prettyprint
@ -416,20 +414,20 @@ GEM
rack (>= 1.3) rack (>= 1.3)
rackup (2.3.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (8.1.2) rails (8.1.3)
actioncable (= 8.1.2) actioncable (= 8.1.3)
actionmailbox (= 8.1.2) actionmailbox (= 8.1.3)
actionmailer (= 8.1.2) actionmailer (= 8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
actiontext (= 8.1.2) actiontext (= 8.1.3)
actionview (= 8.1.2) actionview (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activemodel (= 8.1.2) activemodel (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activestorage (= 8.1.2) activestorage (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.1.2) railties (= 8.1.3)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@ -437,9 +435,9 @@ GEM
rails-html-sanitizer (1.7.0) rails-html-sanitizer (1.7.0)
loofah (~> 2.25) loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.2) railties (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@ -487,11 +485,10 @@ GEM
rswag-ui (2.17.0) rswag-ui (2.17.0)
actionpack (>= 5.2, < 8.2) actionpack (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2) railties (>= 5.2, < 8.2)
rubocop (1.85.1) rubocop (1.86.0)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
mcp (~> 0.6)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
@ -587,10 +584,10 @@ GEM
tailwindcss-ruby (4.2.1-x86_64-linux-gnu) tailwindcss-ruby (4.2.1-x86_64-linux-gnu)
tailwindcss-ruby (4.2.1-x86_64-linux-musl) tailwindcss-ruby (4.2.1-x86_64-linux-musl)
thor (1.5.0) thor (1.5.0)
thruster (0.1.19) thruster (0.1.20)
thruster (0.1.19-aarch64-linux) thruster (0.1.20-aarch64-linux)
thruster (0.1.19-arm64-darwin) thruster (0.1.20-arm64-darwin)
thruster (0.1.19-x86_64-linux) thruster (0.1.20-x86_64-linux)
timeout (0.6.1) timeout (0.6.1)
tsort (0.2.0) tsort (0.2.0)
turbo-rails (2.0.23) turbo-rails (2.0.23)
@ -608,7 +605,7 @@ GEM
vite_rails (3.10.0) vite_rails (3.10.0)
railties (>= 5.1, < 9) railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2) vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.10.0) vite_ruby (3.10.1)
dry-cli (>= 0.7, < 2) dry-cli (>= 0.7, < 2)
logger (~> 1.6) logger (~> 1.6)
mutex_m mutex_m

View file

@ -65,7 +65,7 @@ class InertiaController < ApplicationController
def inertia_primary_links def inertia_primary_links
links = [] links = []
links << inertia_link("Home", root_path, active: helpers.current_page?(root_path), inertia: true) links << inertia_link("Home", root_path, active: helpers.current_page?(root_path), inertia: true)
links << inertia_link("Leaderboards", leaderboards_path, active: helpers.current_page?(leaderboards_path)) links << inertia_link("Leaderboards", leaderboards_path, active: helpers.current_page?(leaderboards_path), inertia: true)
if current_user if current_user
links << inertia_link("Projects", my_projects_path, active: request.path.start_with?("/my/projects"), inertia: true) links << inertia_link("Projects", my_projects_path, active: request.path.start_with?("/my/projects"), inertia: true)

View file

@ -1,29 +1,24 @@
class LeaderboardsController < ApplicationController class LeaderboardsController < InertiaController
PER_PAGE = 100 layout "inertia"
LEADERBOARD_SCOPES = %w[global country].freeze
def index def index
@period_type = validated_period_type period_type = validated_period_type
load_country_context country = load_country_context
@leaderboard_scope = validated_leaderboard_scope leaderboard_scope = validated_leaderboard_scope(country)
@leaderboard = LeaderboardService.get(period: @period_type, date: start_date)
@leaderboard.nil? ? flash.now[:notice] = "Leaderboard is being updated..." : load_metadata
end
def entries leaderboard = LeaderboardService.get(period: period_type, date: Date.current)
@period_type = validated_period_type
load_country_context
@leaderboard_scope = validated_leaderboard_scope
@leaderboard = LeaderboardService.get(period: @period_type, date: start_date)
return head :no_content unless @leaderboard&.persisted?
page = [ (params[:page] || 1).to_i, 1 ].max render inertia: "Leaderboards/Index", props: {
@entries = leaderboard_entries_scope.includes(:user).order(total_seconds: :desc) period_type: period_type.to_s,
.offset((page - 1) * PER_PAGE).limit(PER_PAGE) scope: leaderboard_scope.to_s,
@active_projects = Cache::ActiveProjectsJob.perform_now country: country,
@offset = (page - 1) * PER_PAGE leaderboard: leaderboard_metadata(leaderboard),
is_logged_in: current_user.present?,
render partial: "entries", locals: { entries: @entries, active_projects: @active_projects, offset: @offset } github_uid_blank: current_user&.github_uid.blank? || false,
github_auth_path: "/auth/github",
settings_path: my_settings_path,
entries: InertiaRails.defer { entries_payload(leaderboard, leaderboard_scope, country) }
}
end end
private private
@ -33,42 +28,68 @@ class LeaderboardsController < ApplicationController
%w[daily last_7_days].include?(p) ? p.to_sym : :daily %w[daily last_7_days].include?(p) ? p.to_sym : :daily
end end
def validated_leaderboard_scope def validated_leaderboard_scope(country)
requested_scope = params[:scope].to_s requested = params[:scope].to_s
requested_scope = "global" unless LEADERBOARD_SCOPES.include?(requested_scope) requested = "global" unless %w[global country].include?(requested)
requested_scope = "global" if requested_scope == "country" && !@country_scope_available requested = "global" if requested == "country" && !country[:available]
requested_scope.to_sym requested.to_sym
end end
def load_country_context def load_country_context
country = ISO3166::Country.new(current_user&.country_code) c = ISO3166::Country.new(current_user&.country_code)
@country_code = country&.alpha2 {
@country_name = country&.common_name code: c&.alpha2,
@country_scope_available = @country_code.present? && @country_name.present? name: c&.common_name,
available: c&.alpha2.present? && c&.common_name.present?
}
end end
def country_scope? def leaderboard_metadata(leaderboard)
@leaderboard_scope == :country && @country_scope_available return nil unless leaderboard&.persisted?
{
date_range_text: leaderboard.date_range_text,
updated_at: leaderboard.updated_at&.iso8601,
finished_generating: leaderboard.finished_generating?,
generation_duration_seconds: leaderboard.generation_duration_seconds
}
end end
def leaderboard_entries_scope def entries_payload(leaderboard, scope, country)
entries_scope = @leaderboard.entries return { entries: [], total: 0 } unless leaderboard&.persisted?
return entries_scope unless country_scope?
entries_scope.joins(:user).where(users: { country_code: @country_code }) country_code = (scope == :country && country[:available]) ? country[:code] : nil
end payload = LeaderboardPageCache.fetch(
leaderboard: leaderboard,
scope: scope,
country_code: country_code
)
def start_date active_projects = Cache::ActiveProjectsJob.perform_now
@start_date ||= Date.current
end
def load_metadata entries = payload[:entries].map do |e|
return unless @leaderboard.persisted? user = e[:user]
proj = active_projects&.dig(e[:user_id])
{
user_id: e[:user_id],
total_seconds: e[:total_seconds],
streak_count: e[:streak_count],
is_current_user: e[:user_id] == current_user&.id,
user: {
display_name: user[:display_name],
avatar_url: user[:avatar_url],
profile_path: user[:profile_path],
verified: user[:verified],
country_code: user[:country_code],
red: user[:red]
},
active_project: proj ? { name: proj.project_name, repo_url: proj.repo_url } : nil
}
end
entries_scope = leaderboard_entries_scope {
ids = entries_scope.distinct.pluck(:user_id) entries: entries,
@user_on_leaderboard = current_user && ids.include?(current_user.id) total: payload[:total_entries]
@untracked_entries = 0 }
@total_entries = entries_scope.count
end end
end end

View file

@ -6,6 +6,7 @@
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import plur from "plur"; import plur from "plur";
import { streakTheme, streakLabel } from "../utils";
type NavLink = { type NavLink = {
label: string; label: string;
@ -154,42 +155,6 @@
const footerStatsText = () => const footerStatsText = () =>
`${layout.footer.heartbeat_recent_count} ${plur("heartbeat", layout.footer.heartbeat_recent_count)} (${layout.footer.heartbeat_recent_imported_count} imported) in the past 24 hours. (DB: ${layout.footer.query_count} ${plur("query", layout.footer.query_count)}, ${layout.footer.query_cache_count} cached) (CACHE: ${layout.footer.cache_hits} hits, ${layout.footer.cache_misses} misses) (${layout.footer.requests_per_second})`; `${layout.footer.heartbeat_recent_count} ${plur("heartbeat", layout.footer.heartbeat_recent_count)} (${layout.footer.heartbeat_recent_imported_count} imported) in the past 24 hours. (DB: ${layout.footer.query_count} ${plur("query", layout.footer.query_count)}, ${layout.footer.query_cache_count} cached) (CACHE: ${layout.footer.cache_hits} hits, ${layout.footer.cache_misses} misses) (${layout.footer.requests_per_second})`;
const streakThemeClasses = (streakDays: number) => {
if (streakDays >= 30) {
return {
bg: "from-blue/20 to-purple/20",
hbg: "hover:from-blue/30 hover:to-purple/30",
bc: "border-blue",
ic: "text-blue group-hover:text-blue",
tc: "text-blue group-hover:text-blue",
tm: "text-blue",
};
}
if (streakDays >= 7) {
return {
bg: "from-red/20 to-orange/20",
hbg: "hover:from-red/30 hover:to-orange/30",
bc: "border-red",
ic: "text-red group-hover:text-red",
tc: "text-red group-hover:text-red",
tm: "text-red",
};
}
return {
bg: "from-orange/20 to-yellow/20",
hbg: "hover:from-orange/30 hover:to-yellow/30",
bc: "border-orange",
ic: "text-orange group-hover:text-orange",
tc: "text-orange group-hover:text-orange",
tm: "text-orange",
};
};
const streakLabel = (streakDays: number) =>
streakDays > 30 ? "30+" : `${streakDays}`;
const adminLevelLabel = (adminLevel?: AdminLevel | null) => { const adminLevelLabel = (adminLevel?: AdminLevel | null) => {
if (adminLevel === "superadmin") return "Superadmin"; if (adminLevel === "superadmin") return "Superadmin";
if (adminLevel === "admin") return "Admin"; if (adminLevel === "admin") return "Admin";
@ -361,11 +326,9 @@
</div> </div>
{#if layout.nav.current_user.streak_days && layout.nav.current_user.streak_days > 0} {#if layout.nav.current_user.streak_days && layout.nav.current_user.streak_days > 0}
{@const streakTheme = streakThemeClasses( {@const streak = streakTheme(layout.nav.current_user.streak_days)}
layout.nav.current_user.streak_days,
)}
<div <div
class={`inline-flex items-center gap-1 px-2 py-1 bg-gradient-to-r ${streakTheme.bg} border ${streakTheme.bc} rounded-lg transition-all duration-200 ${streakTheme.hbg} group`} class={`inline-flex items-center gap-1 px-2 py-1 bg-gradient-to-r ${streak.bg} border ${streak.bc} rounded-lg transition-all duration-200 ${streak.hbg} group`}
title={layout.nav.current_user.streak_days > 30 title={layout.nav.current_user.streak_days > 30
? "30+ daily streak" ? "30+ daily streak"
: `${layout.nav.current_user.streak_days} day streak`} : `${layout.nav.current_user.streak_days} day streak`}
@ -375,7 +338,7 @@
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
class={`${streakTheme.ic} transition-colors duration-200 group-hover:animate-pulse`} class={`${streak.ic} transition-colors duration-200 group-hover:animate-pulse`}
> >
<path <path
fill="currentColor" fill="currentColor"
@ -384,11 +347,10 @@
</svg> </svg>
<span <span
class={`text-md font-semibold ${streakTheme.tc} transition-colors duration-200`} class={`text-md font-semibold ${streak.tc} transition-colors duration-200`}
> >
{streakLabel(layout.nav.current_user.streak_days)} {streakLabel(layout.nav.current_user.streak_days)}
<span class={`ml-1 font-normal ${streakTheme.tm}`} <span class={`ml-1 font-normal ${streak.tm}`}>day streak</span
>day streak</span
> >
</span> </span>
</div> </div>

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Link } from "@inertiajs/svelte"; import { Link } from "@inertiajs/svelte";
import type { ActivityGraphData } from "../../../types/index"; import type { ActivityGraphData } from "../../../types/index";
import { durationInWords } from "../../../utils";
let { data }: { data: ActivityGraphData } = $props(); let { data }: { data: ActivityGraphData } = $props();
@ -24,14 +25,6 @@
return "activity-cell--1"; return "activity-cell--1";
} }
function durationInWords(seconds: number): string {
if (seconds < 60) return "less than a minute";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `about ${hours} ${hours === 1 ? "hour" : "hours"}`;
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
}
const dates = $derived(buildDateRange(data.start_date, data.end_date)); const dates = $derived(buildDateRange(data.start_date, data.end_date));
</script> </script>

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { BarChart, Tooltip } from "layerchart"; import { BarChart, Tooltip } from "layerchart";
import { secondsToDisplay, secondsToCompactDisplay } from "../../../utils";
let { let {
weeklyStats, weeklyStats,
@ -74,18 +75,13 @@
bottom: 20, bottom: 20,
})); }));
function formatDuration(value: number): string { // the duplication here is intentional.
if (value === 0) return "0s"; function formatYAxis(value: number): string {
const hours = Math.floor(value / 3600); return secondsToCompactDisplay(value);
const minutes = Math.floor((value % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
} }
function formatYAxis(value: number): string { function formatDuration(value: number): string {
if (value === 0) return "0s"; return secondsToDisplay(value);
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
return hours > 0 ? `${hours}h` : `${minutes}m`;
} }
type TimelineDatum = Record<string, string | number>; type TimelineDatum = Record<string, string | number>;

View file

@ -1,36 +1,7 @@
export const pluralize = (count: number, singular: string, plural: string) => export {
count === 1 ? singular : plural; pluralize,
toSentence,
export const toSentence = (items: string[]) => { secondsToDisplay,
if (items.length === 0) return ""; percentOf,
if (items.length === 1) return items[0]; logScale,
if (items.length === 2) return `${items[0]} and ${items[1]}`; } from "../../../utils";
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
};
export const secondsToDisplay = (seconds?: number) => {
if (!seconds) return "0m";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
};
export const percentOf = (value: number, max: number) => {
if (!max || max === 0) return 0;
return Math.max(2, Math.round((value / max) * 100));
};
export const logScale = (value: number, maxVal: number): number => {
if (value === 0) return 0;
const minPercent = 5;
const maxPercent = 100;
const linearRatio = value / maxVal;
const logRatio = Math.log(value + 1) / Math.log(maxVal + 1);
const linearWeight = 0.8;
const logWeight = 0.2;
const scaled =
minPercent +
(linearWeight * linearRatio + logWeight * logRatio) *
(maxPercent - minPercent);
return Math.min(Math.round(scaled), maxPercent);
};

View file

@ -0,0 +1,311 @@
<script lang="ts">
import { Deferred, Link } from "@inertiajs/svelte";
import CountryFlag from "../../components/CountryFlag.svelte";
import Button from "../../components/Button.svelte";
import type {
LeaderboardMeta,
LeaderboardCountry,
LeaderboardEntriesPayload,
} from "../../types";
import {
secondsToDetailedDisplay,
timeAgo,
rankDisplay,
streakTheme,
streakLabel,
tabClass,
} from "./utils";
let {
period_type,
scope,
country,
leaderboard,
is_logged_in,
github_uid_blank,
github_auth_path,
settings_path,
entries,
}: {
period_type: string;
scope: string;
country: LeaderboardCountry;
leaderboard: LeaderboardMeta | null;
is_logged_in: boolean;
github_uid_blank: boolean;
github_auth_path: string;
settings_path: string;
entries?: LeaderboardEntriesPayload;
} = $props();
const dateRangeText = $derived(
leaderboard?.date_range_text ??
(period_type === "last_7_days"
? (() => {
const end = new Date();
const start = new Date(end);
start.setDate(start.getDate() - 6);
return `${start.toLocaleDateString("en-US", { month: "long", day: "numeric" })} - ${end.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`;
})()
: new Date().toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})),
);
</script>
<svelte:head>
<title>Leaderboards | Hackatime</title>
</svelte:head>
<div class="max-w-6xl mx-auto px-3 py-4 sm:p-6">
<div class="mb-8 space-y-4">
<h1 class="text-3xl font-bold text-surface-content">Leaderboards</h1>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<!-- Scope tabs -->
<div
class="inline-flex max-w-full overflow-x-auto rounded-full bg-darkless p-1 gap-1"
>
<Link
href={`/leaderboards?period_type=${period_type}&scope=global`}
class={tabClass(scope === "global")}
preserveState
>
Global
</Link>
{#if country.available}
<Link
href={`/leaderboards?period_type=${period_type}&scope=country`}
class={`${tabClass(scope === "country")} inline-flex items-center justify-center gap-2`}
preserveState
>
<CountryFlag
countryCode={country.code}
class="inline-block w-5 h-5"
/>
<span class="max-w-48 truncate">{country.name}</span>
</Link>
{:else}
<span
class="text-center px-4 py-2 rounded-full text-sm font-medium text-muted/60 bg-darker cursor-not-allowed whitespace-nowrap"
>
Country
</span>
{/if}
</div>
<!-- Period tabs -->
<div
class="inline-flex max-w-full overflow-x-auto rounded-full bg-darkless p-1 gap-1"
>
<Link
href={`/leaderboards?period_type=daily&scope=${scope}`}
class={tabClass(period_type === "daily")}
preserveState
>
Last 24 Hours
</Link>
<Link
href={`/leaderboards?period_type=last_7_days&scope=${scope}`}
class={tabClass(period_type === "last_7_days")}
preserveState
>
Last 7 Days
</Link>
</div>
</div>
{#if is_logged_in && !country.available}
<p class="text-xs text-muted">
Set your country in
<Link
href={settings_path}
class="text-accent hover:text-cyan transition-colors">settings</Link
>
to unlock regional leaderboards.
</p>
{/if}
{#if github_uid_blank}
<div
class="bg-darker border border-primary rounded-lg p-4 flex flex-col sm:flex-row sm:items-center gap-3"
>
<span class="text-surface-content"
>Connect your GitHub to qualify for the leaderboard.</span
>
<Button href={github_auth_path} native size="md">Connect GitHub</Button>
</div>
{/if}
<div class="text-muted text-sm flex flex-wrap items-center gap-x-2 gap-y-1">
{dateRangeText}
{#if leaderboard?.finished_generating && leaderboard?.updated_at}
<span class="italic">• Updated {timeAgo(leaderboard.updated_at)}.</span>
{/if}
</div>
</div>
<div class="bg-elevated rounded-xl border border-primary overflow-hidden">
{#if leaderboard}
<Deferred data="entries">
{#snippet fallback()}
<div class="divide-y divide-gray-800">
{#each Array(20) as _}
<div class="flex items-center p-2 animate-pulse">
<div class="w-12 h-6 bg-darkless rounded shrink-0"></div>
<div class="w-8 h-8 bg-darkless rounded-full mx-4"></div>
<div class="flex-1">
<div class="h-4 w-32 bg-darkless rounded"></div>
</div>
<div class="h-4 w-16 bg-darkless rounded shrink-0"></div>
</div>
{/each}
</div>
{/snippet}
{#snippet children()}
{#if entries && entries.total > 0}
<div class="divide-y divide-gray-800">
{#each entries.entries as entry, i}
{@const theme = streakTheme(entry.streak_count)}
<div
class="flex items-center p-2 sm:p-3 hover:bg-dark transition-colors duration-200 gap-2 sm:gap-0 {entry.is_current_user
? 'bg-dark border-l-4 border-l-primary'
: ''} {entry.user.red ? 'opacity-40 hover:opacity-60' : ''}"
>
<!-- Rank -->
<div
class="w-8 sm:w-12 shrink-0 text-center font-medium text-muted"
>
{#if i <= 2}
<span class="text-xl sm:text-2xl">{rankDisplay(i)}</span>
{:else}
<span class="text-base sm:text-lg">{i + 1}</span>
{/if}
</div>
<!-- User info -->
<div class="flex-1 mx-1 sm:mx-4 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<div class="user-info flex items-center gap-2">
{#if entry.user.avatar_url}
<img
src={entry.user.avatar_url}
alt="{entry.user.display_name}'s avatar"
class="w-8 h-8 rounded-full aspect-square border border-surface-200"
loading="lazy"
/>
{/if}
<span class="inline-flex items-center gap-1">
{#if entry.user.profile_path}
<Link
href={entry.user.profile_path}
class="text-blue hover:underline"
>
{entry.user.display_name}
</Link>
{:else}
{entry.user.display_name}
{/if}
</span>
{#if entry.user.country_code}
<CountryFlag countryCode={entry.user.country_code} />
{/if}
</div>
{#if entry.active_project}
<span
class="text-xs italic text-muted truncate max-w-37.5 sm:max-w-none"
>
working on
<a
href={entry.active_project.repo_url}
target="_blank"
class="text-accent hover:text-cyan transition-colors"
>
{entry.active_project.name}
</a>
</span>
{/if}
{#if entry.streak_count > 0}
<div
class="inline-flex items-center gap-1 px-2 py-1 bg-linear-to-r {theme.bg} border {theme.bc} rounded-lg transition-all duration-200 {theme.hbg} group"
title={entry.streak_count > 30
? "30+ daily streak"
: `${entry.streak_count} day streak`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
class="{theme.ic} transition-colors duration-200 group-hover:animate-pulse"
>
<path
fill="currentColor"
d="M10 2c0-.88 1.056-1.331 1.692-.722c1.958 1.876 3.096 5.995 1.75 9.12l-.08.174l.012.003c.625.133 1.203-.43 2.303-2.173l.14-.224a1 1 0 0 1 1.582-.153C18.733 9.46 20 12.402 20 14.295C20 18.56 16.409 22 12 22s-8-3.44-8-7.706c0-2.252 1.022-4.716 2.632-6.301l.605-.589c.241-.236.434-.43.618-.624C9.285 5.268 10 3.856 10 2"
/>
</svg>
<span
class="text-md font-semibold {theme.tc} transition-colors duration-200"
>
{streakLabel(entry.streak_count)}
</span>
</div>
{/if}
</div>
</div>
<!-- Duration -->
<div
class="shrink-0 font-mono text-xs sm:text-sm text-surface-content font-medium whitespace-nowrap"
>
{secondsToDetailedDisplay(entry.total_seconds)}
</div>
</div>
{/each}
</div>
{#if leaderboard?.finished_generating && leaderboard?.generation_duration_seconds != null}
<div
class="px-4 py-2 text-xs italic text-muted border-t border-primary"
>
Generated in {leaderboard.generation_duration_seconds} seconds
</div>
{/if}
{:else}
<div class="py-16 text-center px-3">
<h3 class="text-xl font-medium text-surface-content mb-2">
No data available
</h3>
<p class="text-muted">
Check back later for {period_type === "last_7_days"
? "last 7 days"
: "last 24 hours"} results!
</p>
</div>
{/if}
{/snippet}
</Deferred>
{:else}
<div class="py-16 text-center px-3">
<h3 class="text-xl font-medium text-surface-content mb-2">
Leaderboard is being generated...
</h3>
<p class="text-muted">
Check back in a moment for {scope === "country" && country.name
? `${country.name} `
: ""}{period_type === "last_7_days"
? "last 7 days"
: "last 24 hours"} results!
</p>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,10 @@
export {
secondsToDetailedDisplay,
timeAgo,
rankDisplay,
streakTheme,
streakLabel,
} from "../../utils";
export const tabClass = (active: boolean) =>
`text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap ${active ? "bg-primary text-on-primary" : "text-muted hover:text-surface-content"}`;

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { router } from "@inertiajs/svelte"; import { router } from "@inertiajs/svelte";
import { secondsToDisplay } from "../../../utils";
import Button from "../../../components/Button.svelte"; import Button from "../../../components/Button.svelte";
import Modal from "../../../components/Modal.svelte"; import Modal from "../../../components/Modal.svelte";
import MultiSelectCombobox from "../../../components/MultiSelectCombobox.svelte"; import MultiSelectCombobox from "../../../components/MultiSelectCombobox.svelte";
@ -87,16 +88,6 @@
} }
}); });
function formatDuration(seconds: number) {
const totalMinutes = Math.max(Math.floor(seconds / 60), 0);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h`;
return `${minutes}m`;
}
function formatPeriod(period: ProgrammingGoal["period"]) { function formatPeriod(period: ProgrammingGoal["period"]) {
if (period === "day") return "Daily"; if (period === "day") return "Daily";
if (period === "week") return "Weekly"; if (period === "week") return "Weekly";
@ -236,7 +227,7 @@
> >
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm font-semibold text-surface-content"> <p class="text-sm font-semibold text-surface-content">
{formatPeriod(goal.period)}: {formatDuration( {formatPeriod(goal.period)}: {secondsToDisplay(
goal.target_seconds, goal.target_seconds,
)} )}
</p> </p>

View file

@ -6,27 +6,39 @@ export type FlashData = {
export type SharedProps = {}; export type SharedProps = {};
export type LeaderboardEntryUser = { export type LeaderboardEntryUser = {
id: number;
display_name: string; display_name: string;
avatar_url: string; avatar_url: string | null;
profile_path: string; profile_path: string | null;
}; verified: boolean;
country_code: string | null;
export type LeaderboardActiveProject = { red: boolean;
name: string;
repo_url: string | null;
}; };
export type LeaderboardEntry = { export type LeaderboardEntry = {
rank: number; user_id: number;
total_seconds: number;
streak_count: number;
is_current_user: boolean; is_current_user: boolean;
user: LeaderboardEntryUser; user: LeaderboardEntryUser;
total_seconds: number; active_project: { name: string; repo_url: string | null } | null;
total_display: string; };
streak_count: number;
active_project: LeaderboardActiveProject | null; export type LeaderboardMeta = {
needs_github_link: boolean; date_range_text: string;
settings_path: string; updated_at: string;
finished_generating: boolean;
generation_duration_seconds: number | null;
};
export type LeaderboardCountry = {
code: string | null;
name: string | null;
available: boolean;
};
export type LeaderboardEntriesPayload = {
entries: LeaderboardEntry[];
total: number;
}; };
export type ActivityGraphData = { export type ActivityGraphData = {

112
app/javascript/utils.ts Normal file
View file

@ -0,0 +1,112 @@
export const pluralize = (count: number, singular: string, plural: string) =>
count === 1 ? singular : plural;
export const toSentence = (items: string[]) => {
if (items.length === 0) return "";
if (items.length === 1) return items[0];
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
};
export const secondsToDisplay = (seconds?: number) => {
if (!seconds) return "0m";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? (minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`) : `${minutes}m`;
};
export const secondsToDetailedDisplay = (seconds: number) => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts: string[] = [];
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
if (s > 0) parts.push(`${s}s`);
return parts.join(" ") || "0s";
};
export const secondsToCompactDisplay = (seconds?: number) => {
if (!seconds) return "0m";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h` : `${minutes}m`;
};
export const durationInWords = (seconds: number): string => {
if (seconds < 60) return "less than a minute";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `about ${hours} ${hours === 1 ? "hour" : "hours"}`;
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
};
export const percentOf = (value: number, max: number) => {
if (!max || max === 0) return 0;
return Math.max(2, Math.round((value / max) * 100));
};
export const logScale = (value: number, maxVal: number): number => {
if (value === 0) return 0;
const minPercent = 5;
const maxPercent = 100;
const linearRatio = value / maxVal;
const logRatio = Math.log(value + 1) / Math.log(maxVal + 1);
const linearWeight = 0.8;
const logWeight = 0.2;
const scaled =
minPercent +
(linearWeight * linearRatio + logWeight * logRatio) *
(maxPercent - minPercent);
return Math.min(Math.round(scaled), maxPercent);
};
export const timeAgo = (isoString: string) => {
const diff = Date.now() - new Date(isoString).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"} ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
const days = Math.floor(hours / 24);
return `${days} day${days === 1 ? "" : "s"} ago`;
};
export const streakTheme = (count: number) => {
if (count >= 30)
return {
bg: "from-blue/20 to-purple/20",
hbg: "hover:from-blue/30 hover:to-purple/30",
bc: "border-blue",
ic: "text-blue",
tc: "text-blue",
tm: "text-blue",
};
if (count >= 7)
return {
bg: "from-red/20 to-orange/20",
hbg: "hover:from-red/30 hover:to-orange/30",
bc: "border-red",
ic: "text-red",
tc: "text-red",
tm: "text-red",
};
return {
bg: "from-orange/20 to-yellow/20",
hbg: "hover:from-orange/30 hover:to-yellow/30",
bc: "border-orange",
ic: "text-orange",
tc: "text-orange",
tm: "text-orange",
};
};
export const streakLabel = (count: number) =>
count > 30 ? "30+" : `${count}`;
export const rankDisplay = (index: number) => {
if (index === 0) return "🥇";
if (index === 1) return "🥈";
if (index === 2) return "🥉";
return `${index + 1}`;
};

View file

@ -18,6 +18,7 @@ class LeaderboardUpdateJob < ApplicationJob
private private
def build_leaderboard(date, period, force_update = false) def build_leaderboard(date, period, force_update = false)
generation_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
board = ::Leaderboard.find_or_create_by!( board = ::Leaderboard.find_or_create_by!(
start_date: date, start_date: date,
period_type: period, period_type: period,
@ -61,12 +62,21 @@ class LeaderboardUpdateJob < ApplicationJob
board.entries.delete_all board.entries.delete_all
end end
board.update!(finished_generating_at: timestamp) finished_at = Time.current
generation_duration_seconds = [
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - generation_started_at).ceil,
1
].max
board.update!(
finished_generating_at: finished_at,
generation_duration_seconds: generation_duration_seconds
)
end end
# Cache the board
cache_key = LeaderboardCache.global_key(period, date) cache_key = LeaderboardCache.global_key(period, date)
LeaderboardCache.write(cache_key, board) LeaderboardCache.write(cache_key, board)
LeaderboardPageCache.warm(leaderboard: board)
Rails.logger.debug "Persisted leaderboard for #{period} with #{board.entries.count} entries" Rails.logger.debug "Persisted leaderboard for #{period} with #{board.entries.count} entries"

View file

@ -0,0 +1,68 @@
class LeaderboardPageCache
CACHE_EXPIRATION = 10.minutes
class << self
def fetch(leaderboard:, scope:, country_code: nil)
Rails.cache.fetch(cache_key(leaderboard, scope, country_code), expires_in: CACHE_EXPIRATION) do
build_payload(leaderboard: leaderboard, scope: scope, country_code: country_code)
end
end
def warm(leaderboard:)
fetch(leaderboard: leaderboard, scope: :global)
end
private
def cache_key(leaderboard, scope, country_code)
scope_suffix = scope.to_sym == :country ? (country_code.presence || "none") : "global"
"leaderboard_page/#{leaderboard.cache_key_with_version}/#{scope}/#{scope_suffix}"
end
def build_payload(leaderboard:, scope:, country_code:)
rows = entries_scope(
leaderboard: leaderboard,
scope: scope,
country_code: country_code
).map do |entry|
{
user_id: entry.user_id,
total_seconds: entry.total_seconds,
streak_count: entry.streak_count,
user: serialize_user(entry.user)
}
end
{
total_entries: rows.size,
user_ids: rows.map { |row| row[:user_id] },
entries: rows
}
end
def entries_scope(leaderboard:, scope:, country_code:)
scope_query = leaderboard.entries.order(total_seconds: :desc)
if scope.to_sym == :country && country_code.present?
scope_query = scope_query.joins(:user).where(users: { country_code: country_code })
end
scope_query.includes(user: :email_addresses)
end
def serialize_user(user)
{
id: user.id,
display_name: user.display_name,
avatar_url: user.avatar_url,
profile_path: user.username.present? ? routes.profile_path(user.username) : nil,
verified: user.trust_level == "green",
red: user.red?,
country_code: user.country_code
}
end
def routes
Rails.application.routes.url_helpers
end
end
end

View file

@ -1,38 +0,0 @@
<% entries.each_with_index do |e, i| %>
<div class="flex items-center p-2 sm:p-3 hover:bg-dark transition-colors duration-200 gap-2 sm:gap-0 <%= 'bg-dark border-l-4 border-l-primary' if e.user_id == current_user&.id %> <%= 'opacity-40 hover:opacity-60' if e.user.red? && current_user&.admin_level.in?(%w[admin superadmin]) %>">
<div class="w-8 sm:w-12 shrink-0 text-center font-medium text-muted">
<% idx = offset + i %>
<%=
case idx
when 0
'<span class="text-xl sm:text-2xl">🥇</span>'.html_safe
when 1
'<span class="text-xl sm:text-2xl">🥈</span>'.html_safe
when 2
'<span class="text-xl sm:text-2xl">🥉</span>'.html_safe
else
"<span class=\"text-base sm:text-lg\">#{idx + 1}</span>".html_safe
end
%>
</div>
<div class="flex-1 mx-1 sm:mx-4 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<%= render 'shared/user_mention', user: e.user, show: %i[profile_link verified_badge] %>
<% if (proj = active_projects&.dig(e.user_id)).present? %>
<span class="text-xs italic text-muted truncate max-w-[150px] sm:max-w-none">
working on <%= link_to h(proj.project_name), proj.repo_url, target: '_blank', class: 'text-accent hover:text-cyan transition-colors' %>
<% dev_tool(nil, 'span') do %>
<%= link_to '🌌', visualize_git_url(proj.repo_url), target: '_blank', class: 'ml-1' %>
<% end %>
</span>
<% end %>
<% if e.streak_count > 0 %>
<%= render 'static_pages/streak', user: e.user, streak_count: e.streak_count, turbo_frame: false, icon_size: 16, show_super_class: true %>
<% end %>
</div>
</div>
<div class="shrink-0 font-mono text-xs sm:text-sm text-surface-content font-medium whitespace-nowrap">
<%= short_time_detailed e.total_seconds %>
</div>
</div>
<% end %>

View file

@ -1,10 +0,0 @@
<div class="divide-y divide-gray-800">
<% 10.times do %>
<div class="flex items-center p-2 animate-pulse">
<div class="w-12 h-6 bg-darkless rounded shrink-0"></div>
<div class="w-8 h-8 bg-darkless rounded-full mx-4"></div>
<div class="flex-1"><div class="h-4 w-32 bg-darkless rounded"></div></div>
<div class="h-4 w-16 bg-darkless rounded shrink-0"></div>
</div>
<% end %>
</div>

View file

@ -1,101 +0,0 @@
<div class="max-w-6xl mx-auto px-3 py-4 sm:p-6">
<div class="mb-8 space-y-4">
<h1 class="text-3xl font-bold text-surface-content">Leaderboards</h1>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="inline-flex max-w-full overflow-x-auto rounded-full bg-darkless p-1 gap-1">
<%= link_to 'Global', leaderboards_path(period_type: @period_type, scope: 'global'), class: "text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap #{@leaderboard_scope == :global ? 'bg-primary text-on-primary' : 'text-muted hover:text-surface-content'}" %>
<% if @country_scope_available %>
<%= link_to leaderboards_path(period_type: @period_type, scope: 'country'), class: "text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 inline-flex items-center justify-center gap-2 whitespace-nowrap #{@leaderboard_scope == :country ? 'bg-primary text-on-primary' : 'text-muted hover:text-surface-content'}" do %>
<%= country_to_emoji(@country_code) %>
<span class="max-w-[12rem] truncate"><%= @country_name %></span>
<% end %>
<% else %>
<span class="text-center px-4 py-2 rounded-full text-sm font-medium text-muted/60 bg-darker cursor-not-allowed whitespace-nowrap">Country</span>
<% end %>
</div>
<div class="inline-flex max-w-full overflow-x-auto rounded-full bg-darkless p-1 gap-1">
<%= link_to 'Last 24 Hours', leaderboards_path(period_type: 'daily', scope: @leaderboard_scope), class: "text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap #{@period_type == :daily ? 'bg-primary text-on-primary' : 'text-muted hover:text-surface-content'}" %>
<%= link_to 'Last 7 Days', leaderboards_path(period_type: 'last_7_days', scope: @leaderboard_scope), class: "text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap #{@period_type == :last_7_days ? 'bg-primary text-on-primary' : 'text-muted hover:text-surface-content'}" %>
</div>
</div>
<% if current_user && !@country_scope_available %>
<p class="text-xs text-muted">
Set your country in
<%= link_to 'settings', my_settings_path, class: 'text-accent hover:text-cyan transition-colors' %>
to unlock regional leaderboards.
</p>
<% end %>
<% if current_user && current_user.github_uid.blank? %>
<div class="bg-darker border border-primary rounded-lg p-4 flex flex-col sm:flex-row sm:items-center gap-3">
<span class="text-surface-content">Connect your GitHub to qualify for the leaderboard.</span>
<%= link_to 'Connect GitHub', '/auth/github', class: 'bg-primary hover:bg-primary/75 text-on-primary font-medium px-4 py-2 rounded-lg transition-colors duration-200 text-center shrink-0 w-fit' %>
</div>
<% end %>
<div class="text-muted text-sm flex flex-wrap items-center gap-x-2 gap-y-1">
<% if @leaderboard %>
<%= @leaderboard.date_range_text %>
<% if @leaderboard.finished_generating? && @leaderboard.persisted? %>
<span class="italic">• Updated <%= time_ago_in_words(@leaderboard.updated_at) %> ago.</span>
<% end %>
<% else %>
<%=
case @period_type
when :last_7_days
"#{(Date.current - 6.days).strftime('%B %d')} - #{Date.current.strftime('%B %d, %Y')}"
else
Date.current.strftime('%B %d, %Y')
end
%>
<% end %>
</div>
</div>
<div class="bg-elevated rounded-xl border border-primary overflow-hidden">
<% if @leaderboard&.persisted? %>
<% if @total_entries && @total_entries > 0 %>
<div id="leaderboard-entries" class="divide-y divide-gray-800" data-controller="infinite-scroll" data-infinite-scroll-url-value="<%= entries_leaderboards_path(period_type: @period_type, scope: @leaderboard_scope) %>" data-infinite-scroll-page-value="1" data-infinite-scroll-total-value="<%= @total_entries %>">
<%= render 'loading' %>
</div>
<% else %>
<div class="py-16 text-center px-3">
<h3 class="text-xl font-medium text-surface-content mb-2">No data available</h3>
<p class="text-muted">
Check back later for
<%= @period_type == :last_7_days ? 'last 7 days' : 'last 24 hours' %>
results!
</p>
</div>
<% end %>
<% if @leaderboard_scope == :global && @untracked_entries.to_i.positive? && !@user_on_leaderboard %>
<div class="px-4 py-3 text-sm text-muted border-t border-primary">
Don't see yourself on the leaderboard? You're probably one of the
<%= pluralize(@untracked_entries, 'user') %>
who haven't
<%= link_to 'updated their wakatime config', my_settings_path, target: '_blank', class: 'text-accent hover:text-cyan transition-colors' %>.
</div>
<% end %>
<% if @leaderboard.finished_generating? %>
<div class="px-4 py-2 text-xs italic text-muted border-t border-primary">Generated in <%= @leaderboard.finished_generating_at - @leaderboard.created_at %> seconds</div>
<% end %>
<% else %>
<div class="py-16 text-center px-3">
<h3 class="text-xl font-medium text-surface-content mb-2">No data available</h3>
<p class="text-muted">
Check back later for
<%= @leaderboard_scope == :country ? "#{@country_name} " : '' %>
<%= @period_type == :last_7_days ? 'last 7 days' : 'last 24 hours' %>
results!
</p>
</div>
<% end %>
</div>
</div>

View file

@ -230,6 +230,8 @@
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
@ -242,13 +244,13 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], "axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bits-ui": ["bits-ui@2.16.3", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg=="], "bits-ui": ["bits-ui@2.16.4", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-76dKbW0DGplG7qnVM3kljE2CmCXqh9tj9OfVo0DvC/ZGRkZNJ/auU/aQnMKBxDnSqRvsz2mZBccBkUKyF4/SoQ=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@ -350,7 +352,7 @@
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.2.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ=="], "esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@ -506,7 +508,7 @@
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.5.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg=="], "prettier-plugin-svelte": ["prettier-plugin-svelte@3.5.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@ -540,7 +542,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.54.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg=="], "svelte": ["svelte@5.55.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw=="],
"svelte-check": ["svelte-check@4.4.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw=="], "svelte-check": ["svelte-check@4.4.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw=="],
@ -572,7 +574,7 @@
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite-plugin-ruby": ["vite-plugin-ruby@5.2.0", "", { "dependencies": { "obug": "^2.0", "tinyglobby": "^0.2.12" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-FoCaok2pV7GrcAqdxniI1r5XWBlSg9HwEwaxdQdXUVFfYkyINVakPeyrSK4PqOVhonBCuoc633g6bDTEC7wkcA=="], "vite-plugin-ruby": ["vite-plugin-ruby@5.2.1", "", { "dependencies": { "obug": "^2.0", "tinyglobby": "^0.2.12" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-wI3F/Yr4e4mEwiMff/cvNwGu8nZok5wrwUjHxO8we+h3y9+qCluO3Y5dzvz6vHJDBya9fKXkltoMwoJhaB2SRg=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],

View file

@ -117,9 +117,7 @@ Rails.application.routes.draw do
get "/leaderboard", to: redirect("/leaderboards", status: 301) get "/leaderboard", to: redirect("/leaderboards", status: 301)
resources :leaderboards, only: [ :index ] do resources :leaderboards, only: [ :index ]
get :entries, on: :collection
end
# Docs routes # Docs routes
# Note: llms.txt and llms-full.txt are served as static files from public/ # Note: llms.txt and llms-full.txt are served as static files from public/

View file

@ -0,0 +1,5 @@
class AddGenerationDurationSecondsToLeaderboards < ActiveRecord::Migration[8.1]
def change
add_column :leaderboards, :generation_duration_seconds, :integer
end
end

3
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_03_22_180603) do ActiveRecord::Schema[8.1].define(version: 2026_03_30_142838) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
@ -347,6 +347,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_22_180603) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "deleted_at" t.datetime "deleted_at"
t.datetime "finished_generating_at" t.datetime "finished_generating_at"
t.integer "generation_duration_seconds"
t.integer "period_type", default: 0, null: false t.integer "period_type", default: 0, null: false
t.date "start_date", null: false t.date "start_date", null: false
t.integer "timezone_offset" t.integer "timezone_offset"

View file

@ -16,18 +16,18 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"axios": "^1.13.6", "axios": "^1.14.0",
"bits-ui": "^2.16.3", "bits-ui": "^2.16.4",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"layerchart": "^1.0.13", "layerchart": "^1.0.13",
"plur": "^6.0.0", "plur": "^6.0.0",
"svelte": "^5.54.0", "svelte": "^5.55.1",
"svelte-check": "^4.4.5", "svelte-check": "^4.4.5",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-ruby": "^5.2.0" "vite-plugin-ruby": "^5.2.1"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.8.1", "prettier": "^3.8.1",

View file

@ -9,7 +9,7 @@ class LeaderboardsControllerTest < ActionDispatch::IntegrationTest
Rails.cache.clear Rails.cache.clear
end end
test "index renders country tab label and preserves scope in period links" do test "index renders with correct period_type and scope props" do
us_user = create_user(username: "us_index_user", country_code: "US") us_user = create_user(username: "us_index_user", country_code: "US")
create_boards_for_today(period_type: :last_7_days) create_boards_for_today(period_type: :last_7_days)
@ -17,12 +17,15 @@ class LeaderboardsControllerTest < ActionDispatch::IntegrationTest
get leaderboards_path(period_type: "last_7_days", scope: "country") get leaderboards_path(period_type: "last_7_days", scope: "country")
assert_response :success assert_response :success
assert_select "a[href='#{leaderboards_path(period_type: "last_7_days", scope: "global")}']", text: "Global" assert_inertia_component "Leaderboards/Index"
assert_select "a[href='#{leaderboards_path(period_type: "last_7_days", scope: "country")}']", text: /United States/ assert_inertia_prop "period_type", "last_7_days"
assert_select "a[href='#{leaderboards_path(period_type: "daily", scope: "country")}']", text: "Last 24 Hours" assert_inertia_prop "scope", "country"
page = inertia_page
assert_equal "US", page.dig("props", "country", "code")
assert page.dig("props", "country", "available")
end end
test "index falls back to global selector state when country is missing" do test "index falls back to global scope when country is missing" do
viewer = create_user(username: "viewer_no_country") viewer = create_user(username: "viewer_no_country")
create_boards_for_today(period_type: :daily) create_boards_for_today(period_type: :daily)
@ -30,30 +33,21 @@ class LeaderboardsControllerTest < ActionDispatch::IntegrationTest
get leaderboards_path(period_type: "daily", scope: "country") get leaderboards_path(period_type: "daily", scope: "country")
assert_response :success assert_response :success
assert_select "span", text: "Country" assert_inertia_component "Leaderboards/Index"
assert_select "a[href='#{leaderboards_path(period_type: "daily", scope: "global")}']" assert_inertia_prop "scope", "global"
page = inertia_page
assert_not page.dig("props", "country", "available")
end end
test "entries clamps page=0 to page 1 instead of erroring" do test "index clamps invalid period_type to daily" do
user = create_user(username: "page_zero_user") user = create_user(username: "bad_period_user2")
board = create_boards_for_today(period_type: :daily).first create_boards_for_today(period_type: :daily)
board.entries.create!(user: user, total_seconds: 100)
sign_in_as(user) sign_in_as(user)
get entries_leaderboards_path(period_type: "daily", page: 0), xhr: true get leaderboards_path(period_type: "bogus")
assert_response :success
end
test "entries clamps negative page to page 1 instead of erroring" do
user = create_user(username: "neg_page_user")
board = create_boards_for_today(period_type: :daily).first
board.entries.create!(user: user, total_seconds: 100)
sign_in_as(user)
get entries_leaderboards_path(period_type: "daily", page: -5), xhr: true
assert_response :success assert_response :success
assert_inertia_prop "period_type", "daily"
end end
test "validated_period_type does not intern arbitrary symbols" do test "validated_period_type does not intern arbitrary symbols" do

View file

@ -27,6 +27,7 @@ class LeaderboardUpdateJobTest < ActiveJob::TestCase
assert_equal [ coded_user.id ], board.entries.order(:user_id).pluck(:user_id) assert_equal [ coded_user.id ], board.entries.order(:user_id).pluck(:user_id)
assert_equal 120, board.entries.find_by!(user_id: coded_user.id).total_seconds assert_equal 120, board.entries.find_by!(user_id: coded_user.id).total_seconds
assert_operator board.generation_duration_seconds, :>=, 1
end end
private private

View file

@ -0,0 +1,63 @@
require "test_helper"
class LeaderboardPageCacheTest < ActiveSupport::TestCase
setup do
Rails.cache.clear
end
teardown do
Rails.cache.clear
end
test "fetch caches serialized global leaderboard rows" do
user = create_user(username: "lbcacheuser", country_code: "US", trust_level: :green)
board = create_board_with_entry(user: user, total_seconds: 321)
payload = LeaderboardPageCache.fetch(leaderboard: board, scope: :global)
assert_equal 1, payload[:total_entries]
assert_equal [ user.id ], payload[:user_ids]
assert_equal 321, payload[:entries].first[:total_seconds]
assert_equal user.display_name, payload[:entries].first.dig(:user, :display_name)
assert_equal true, payload[:entries].first.dig(:user, :verified)
end
test "fetch filters country scoped rows" do
us_user = create_user(username: "lbcache_us", country_code: "US")
ca_user = create_user(username: "lbcache_ca", country_code: "CA")
board = create_board
board.entries.create!(user: us_user, total_seconds: 300)
board.entries.create!(user: ca_user, total_seconds: 200)
payload = LeaderboardPageCache.fetch(leaderboard: board, scope: :country, country_code: "US")
assert_equal 1, payload[:total_entries]
assert_equal [ us_user.id ], payload[:user_ids]
end
private
def create_user(username:, country_code:, trust_level: :blue)
User.create!(
username: username,
country_code: country_code,
timezone: "UTC",
trust_level: trust_level
)
end
def create_board
Leaderboard.create!(
start_date: Date.current,
period_type: :daily,
timezone_utc_offset: nil,
finished_generating_at: Time.current
)
end
def create_board_with_entry(user:, total_seconds:)
board = create_board
board.entries.create!(user: user, total_seconds: total_seconds, streak_count: 2)
board
end
end