mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
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:
parent
8ce245d8c4
commit
28fe4739f5
25 changed files with 811 additions and 442 deletions
159
Gemfile.lock
159
Gemfile.lock
|
|
@ -1,31 +1,31 @@
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.17)
|
||||
action_text-trix (2.1.18)
|
||||
railties
|
||||
actioncable (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actioncable (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionmailbox (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionmailer (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionpack (8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
|
|
@ -33,38 +33,38 @@ GEM
|
|||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.2)
|
||||
actiontext (8.1.3)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionpack (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionview (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activejob (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activerecord (8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activemodel (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activerecord (8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
timeout (>= 0.4.0)
|
||||
activerecord-import (2.2.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activestorage (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.2)
|
||||
activesupport (8.1.3)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
|
|
@ -82,7 +82,7 @@ GEM
|
|||
ast (2.4.3)
|
||||
autotuner (1.1.0)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1229.0)
|
||||
aws-partitions (1.1231.0)
|
||||
aws-sdk-core (3.244.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
|
|
@ -109,7 +109,7 @@ GEM
|
|||
erubi (~> 1.4)
|
||||
parser (>= 2.4)
|
||||
smart_properties
|
||||
bigdecimal (4.0.1)
|
||||
bigdecimal (4.1.0)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.23.0)
|
||||
msgpack (~> 1.2)
|
||||
|
|
@ -189,22 +189,22 @@ GEM
|
|||
faraday-net_http_persistent (2.3.1)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (>= 4.0.4, < 5)
|
||||
ffi (1.17.3-aarch64-linux-gnu)
|
||||
ffi (1.17.3-aarch64-linux-musl)
|
||||
ffi (1.17.3-arm-linux-gnu)
|
||||
ffi (1.17.3-arm-linux-musl)
|
||||
ffi (1.17.3-arm64-darwin)
|
||||
ffi (1.17.3-x86_64-linux-gnu)
|
||||
ffi (1.17.3-x86_64-linux-musl)
|
||||
ffi (1.17.4-aarch64-linux-gnu)
|
||||
ffi (1.17.4-aarch64-linux-musl)
|
||||
ffi (1.17.4-arm-linux-gnu)
|
||||
ffi (1.17.4-arm-linux-musl)
|
||||
ffi (1.17.4-arm64-darwin)
|
||||
ffi (1.17.4-x86_64-linux-gnu)
|
||||
ffi (1.17.4-x86_64-linux-musl)
|
||||
flamegraph (0.9.5)
|
||||
flipper (1.4.0)
|
||||
flipper (1.4.1)
|
||||
concurrent-ruby (< 2)
|
||||
flipper-active_record (1.4.0)
|
||||
flipper-active_record (1.4.1)
|
||||
activerecord (>= 4.2, < 9)
|
||||
flipper (~> 1.4.0)
|
||||
flipper-ui (1.4.0)
|
||||
flipper (~> 1.4.1)
|
||||
flipper-ui (1.4.1)
|
||||
erubi (>= 1.0.0, < 2.0.0)
|
||||
flipper (~> 1.4.0)
|
||||
flipper (~> 1.4.1)
|
||||
rack (>= 1.4, < 4)
|
||||
rack-protection (>= 1.5.3, < 5.0.0)
|
||||
rack-session (>= 1.0.2, < 3.0.0)
|
||||
|
|
@ -245,7 +245,7 @@ GEM
|
|||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
inertia_rails (3.18.0)
|
||||
inertia_rails (3.19.0)
|
||||
railties (>= 6)
|
||||
io-console (0.8.2)
|
||||
irb (1.17.0)
|
||||
|
|
@ -257,7 +257,7 @@ GEM
|
|||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.19.2)
|
||||
json (2.19.3)
|
||||
json-schema (6.2.0)
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
|
|
@ -300,8 +300,6 @@ GEM
|
|||
activesupport (>= 7.1)
|
||||
marcel (1.1.0)
|
||||
matrix (0.4.3)
|
||||
mcp (0.9.0)
|
||||
json-schema (>= 4.1)
|
||||
memory_profiler (1.1.0)
|
||||
mini_magick (5.3.1)
|
||||
logger
|
||||
|
|
@ -357,7 +355,7 @@ GEM
|
|||
activerecord (>= 7.1)
|
||||
request_store (~> 1.4)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.2)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.6.3)
|
||||
|
|
@ -366,7 +364,7 @@ GEM
|
|||
pg (1.6.3-arm64-darwin)
|
||||
pg (1.6.3-x86_64-linux)
|
||||
pg (1.6.3-x86_64-linux-musl)
|
||||
posthog-ruby (3.6.0)
|
||||
posthog-ruby (3.6.1)
|
||||
concurrent-ruby (~> 1)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
|
|
@ -416,20 +414,20 @@ GEM
|
|||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.2)
|
||||
actioncable (= 8.1.2)
|
||||
actionmailbox (= 8.1.2)
|
||||
actionmailer (= 8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actiontext (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
rails (8.1.3)
|
||||
actioncable (= 8.1.3)
|
||||
actionmailbox (= 8.1.3)
|
||||
actionmailer (= 8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actiontext (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.2)
|
||||
railties (= 8.1.3)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
|
|
@ -437,9 +435,9 @@ GEM
|
|||
rails-html-sanitizer (1.7.0)
|
||||
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)
|
||||
railties (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
railties (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
|
|
@ -487,11 +485,10 @@ GEM
|
|||
rswag-ui (2.17.0)
|
||||
actionpack (>= 5.2, < 8.2)
|
||||
railties (>= 5.2, < 8.2)
|
||||
rubocop (1.85.1)
|
||||
rubocop (1.86.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
mcp (~> 0.6)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
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-musl)
|
||||
thor (1.5.0)
|
||||
thruster (0.1.19)
|
||||
thruster (0.1.19-aarch64-linux)
|
||||
thruster (0.1.19-arm64-darwin)
|
||||
thruster (0.1.19-x86_64-linux)
|
||||
thruster (0.1.20)
|
||||
thruster (0.1.20-aarch64-linux)
|
||||
thruster (0.1.20-arm64-darwin)
|
||||
thruster (0.1.20-x86_64-linux)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.23)
|
||||
|
|
@ -608,7 +605,7 @@ GEM
|
|||
vite_rails (3.10.0)
|
||||
railties (>= 5.1, < 9)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
vite_ruby (3.10.0)
|
||||
vite_ruby (3.10.1)
|
||||
dry-cli (>= 0.7, < 2)
|
||||
logger (~> 1.6)
|
||||
mutex_m
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class InertiaController < ApplicationController
|
|||
def inertia_primary_links
|
||||
links = []
|
||||
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
|
||||
links << inertia_link("Projects", my_projects_path, active: request.path.start_with?("/my/projects"), inertia: true)
|
||||
|
|
|
|||
|
|
@ -1,29 +1,24 @@
|
|||
class LeaderboardsController < ApplicationController
|
||||
PER_PAGE = 100
|
||||
LEADERBOARD_SCOPES = %w[global country].freeze
|
||||
class LeaderboardsController < InertiaController
|
||||
layout "inertia"
|
||||
|
||||
def index
|
||||
@period_type = validated_period_type
|
||||
load_country_context
|
||||
@leaderboard_scope = validated_leaderboard_scope
|
||||
@leaderboard = LeaderboardService.get(period: @period_type, date: start_date)
|
||||
@leaderboard.nil? ? flash.now[:notice] = "Leaderboard is being updated..." : load_metadata
|
||||
end
|
||||
period_type = validated_period_type
|
||||
country = load_country_context
|
||||
leaderboard_scope = validated_leaderboard_scope(country)
|
||||
|
||||
def entries
|
||||
@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?
|
||||
leaderboard = LeaderboardService.get(period: period_type, date: Date.current)
|
||||
|
||||
page = [ (params[:page] || 1).to_i, 1 ].max
|
||||
@entries = leaderboard_entries_scope.includes(:user).order(total_seconds: :desc)
|
||||
.offset((page - 1) * PER_PAGE).limit(PER_PAGE)
|
||||
@active_projects = Cache::ActiveProjectsJob.perform_now
|
||||
@offset = (page - 1) * PER_PAGE
|
||||
|
||||
render partial: "entries", locals: { entries: @entries, active_projects: @active_projects, offset: @offset }
|
||||
render inertia: "Leaderboards/Index", props: {
|
||||
period_type: period_type.to_s,
|
||||
scope: leaderboard_scope.to_s,
|
||||
country: country,
|
||||
leaderboard: leaderboard_metadata(leaderboard),
|
||||
is_logged_in: current_user.present?,
|
||||
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
|
||||
|
||||
private
|
||||
|
|
@ -33,42 +28,68 @@ class LeaderboardsController < ApplicationController
|
|||
%w[daily last_7_days].include?(p) ? p.to_sym : :daily
|
||||
end
|
||||
|
||||
def validated_leaderboard_scope
|
||||
requested_scope = params[:scope].to_s
|
||||
requested_scope = "global" unless LEADERBOARD_SCOPES.include?(requested_scope)
|
||||
requested_scope = "global" if requested_scope == "country" && !@country_scope_available
|
||||
requested_scope.to_sym
|
||||
def validated_leaderboard_scope(country)
|
||||
requested = params[:scope].to_s
|
||||
requested = "global" unless %w[global country].include?(requested)
|
||||
requested = "global" if requested == "country" && !country[:available]
|
||||
requested.to_sym
|
||||
end
|
||||
|
||||
def load_country_context
|
||||
country = ISO3166::Country.new(current_user&.country_code)
|
||||
@country_code = country&.alpha2
|
||||
@country_name = country&.common_name
|
||||
@country_scope_available = @country_code.present? && @country_name.present?
|
||||
c = ISO3166::Country.new(current_user&.country_code)
|
||||
{
|
||||
code: c&.alpha2,
|
||||
name: c&.common_name,
|
||||
available: c&.alpha2.present? && c&.common_name.present?
|
||||
}
|
||||
end
|
||||
|
||||
def country_scope?
|
||||
@leaderboard_scope == :country && @country_scope_available
|
||||
def leaderboard_metadata(leaderboard)
|
||||
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
|
||||
|
||||
def leaderboard_entries_scope
|
||||
entries_scope = @leaderboard.entries
|
||||
return entries_scope unless country_scope?
|
||||
def entries_payload(leaderboard, scope, country)
|
||||
return { entries: [], total: 0 } unless leaderboard&.persisted?
|
||||
|
||||
entries_scope.joins(:user).where(users: { country_code: @country_code })
|
||||
end
|
||||
country_code = (scope == :country && country[:available]) ? country[:code] : nil
|
||||
payload = LeaderboardPageCache.fetch(
|
||||
leaderboard: leaderboard,
|
||||
scope: scope,
|
||||
country_code: country_code
|
||||
)
|
||||
|
||||
def start_date
|
||||
@start_date ||= Date.current
|
||||
end
|
||||
active_projects = Cache::ActiveProjectsJob.perform_now
|
||||
|
||||
def load_metadata
|
||||
return unless @leaderboard.persisted?
|
||||
entries = payload[:entries].map do |e|
|
||||
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)
|
||||
@user_on_leaderboard = current_user && ids.include?(current_user.id)
|
||||
@untracked_entries = 0
|
||||
@total_entries = entries_scope.count
|
||||
{
|
||||
entries: entries,
|
||||
total: payload[:total_entries]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import type { Snippet } from "svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import plur from "plur";
|
||||
import { streakTheme, streakLabel } from "../utils";
|
||||
|
||||
type NavLink = {
|
||||
label: string;
|
||||
|
|
@ -154,42 +155,6 @@
|
|||
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})`;
|
||||
|
||||
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) => {
|
||||
if (adminLevel === "superadmin") return "Superadmin";
|
||||
if (adminLevel === "admin") return "Admin";
|
||||
|
|
@ -361,11 +326,9 @@
|
|||
</div>
|
||||
|
||||
{#if layout.nav.current_user.streak_days && layout.nav.current_user.streak_days > 0}
|
||||
{@const streakTheme = streakThemeClasses(
|
||||
layout.nav.current_user.streak_days,
|
||||
)}
|
||||
{@const streak = streakTheme(layout.nav.current_user.streak_days)}
|
||||
<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
|
||||
? "30+ daily streak"
|
||||
: `${layout.nav.current_user.streak_days} day streak`}
|
||||
|
|
@ -375,7 +338,7 @@
|
|||
width="24"
|
||||
height="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
|
||||
fill="currentColor"
|
||||
|
|
@ -384,11 +347,10 @@
|
|||
</svg>
|
||||
|
||||
<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)}
|
||||
<span class={`ml-1 font-normal ${streakTheme.tm}`}
|
||||
>day streak</span
|
||||
<span class={`ml-1 font-normal ${streak.tm}`}>day streak</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { Link } from "@inertiajs/svelte";
|
||||
import type { ActivityGraphData } from "../../../types/index";
|
||||
import { durationInWords } from "../../../utils";
|
||||
|
||||
let { data }: { data: ActivityGraphData } = $props();
|
||||
|
||||
|
|
@ -24,14 +25,6 @@
|
|||
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));
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { BarChart, Tooltip } from "layerchart";
|
||||
import { secondsToDisplay, secondsToCompactDisplay } from "../../../utils";
|
||||
|
||||
let {
|
||||
weeklyStats,
|
||||
|
|
@ -74,18 +75,13 @@
|
|||
bottom: 20,
|
||||
}));
|
||||
|
||||
function formatDuration(value: number): string {
|
||||
if (value === 0) return "0s";
|
||||
const hours = Math.floor(value / 3600);
|
||||
const minutes = Math.floor((value % 3600) / 60);
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
// the duplication here is intentional.
|
||||
function formatYAxis(value: number): string {
|
||||
return secondsToCompactDisplay(value);
|
||||
}
|
||||
|
||||
function formatYAxis(value: number): string {
|
||||
if (value === 0) return "0s";
|
||||
const hours = Math.floor(value / 3600);
|
||||
const minutes = Math.floor((value % 3600) / 60);
|
||||
return hours > 0 ? `${hours}h` : `${minutes}m`;
|
||||
function formatDuration(value: number): string {
|
||||
return secondsToDisplay(value);
|
||||
}
|
||||
|
||||
type TimelineDatum = Record<string, string | number>;
|
||||
|
|
|
|||
|
|
@ -1,36 +1,7 @@
|
|||
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 ? `${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);
|
||||
};
|
||||
export {
|
||||
pluralize,
|
||||
toSentence,
|
||||
secondsToDisplay,
|
||||
percentOf,
|
||||
logScale,
|
||||
} from "../../../utils";
|
||||
|
|
|
|||
311
app/javascript/pages/Leaderboards/Index.svelte
Normal file
311
app/javascript/pages/Leaderboards/Index.svelte
Normal 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>
|
||||
10
app/javascript/pages/Leaderboards/utils.ts
Normal file
10
app/javascript/pages/Leaderboards/utils.ts
Normal 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"}`;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { router } from "@inertiajs/svelte";
|
||||
import { secondsToDisplay } from "../../../utils";
|
||||
import Button from "../../../components/Button.svelte";
|
||||
import Modal from "../../../components/Modal.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"]) {
|
||||
if (period === "day") return "Daily";
|
||||
if (period === "week") return "Weekly";
|
||||
|
|
@ -236,7 +227,7 @@
|
|||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-surface-content">
|
||||
{formatPeriod(goal.period)}: {formatDuration(
|
||||
{formatPeriod(goal.period)}: {secondsToDisplay(
|
||||
goal.target_seconds,
|
||||
)}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -6,27 +6,39 @@ export type FlashData = {
|
|||
export type SharedProps = {};
|
||||
|
||||
export type LeaderboardEntryUser = {
|
||||
id: number;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
profile_path: string;
|
||||
};
|
||||
|
||||
export type LeaderboardActiveProject = {
|
||||
name: string;
|
||||
repo_url: string | null;
|
||||
avatar_url: string | null;
|
||||
profile_path: string | null;
|
||||
verified: boolean;
|
||||
country_code: string | null;
|
||||
red: boolean;
|
||||
};
|
||||
|
||||
export type LeaderboardEntry = {
|
||||
rank: number;
|
||||
user_id: number;
|
||||
total_seconds: number;
|
||||
streak_count: number;
|
||||
is_current_user: boolean;
|
||||
user: LeaderboardEntryUser;
|
||||
total_seconds: number;
|
||||
total_display: string;
|
||||
streak_count: number;
|
||||
active_project: LeaderboardActiveProject | null;
|
||||
needs_github_link: boolean;
|
||||
settings_path: string;
|
||||
active_project: { name: string; repo_url: string | null } | null;
|
||||
};
|
||||
|
||||
export type LeaderboardMeta = {
|
||||
date_range_text: 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 = {
|
||||
|
|
|
|||
112
app/javascript/utils.ts
Normal file
112
app/javascript/utils.ts
Normal 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}`;
|
||||
};
|
||||
|
|
@ -18,6 +18,7 @@ class LeaderboardUpdateJob < ApplicationJob
|
|||
private
|
||||
|
||||
def build_leaderboard(date, period, force_update = false)
|
||||
generation_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
board = ::Leaderboard.find_or_create_by!(
|
||||
start_date: date,
|
||||
period_type: period,
|
||||
|
|
@ -61,12 +62,21 @@ class LeaderboardUpdateJob < ApplicationJob
|
|||
board.entries.delete_all
|
||||
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
|
||||
|
||||
# Cache the board
|
||||
cache_key = LeaderboardCache.global_key(period, date)
|
||||
LeaderboardCache.write(cache_key, board)
|
||||
LeaderboardPageCache.warm(leaderboard: board)
|
||||
|
||||
Rails.logger.debug "Persisted leaderboard for #{period} with #{board.entries.count} entries"
|
||||
|
||||
|
|
|
|||
68
app/services/leaderboard_page_cache.rb
Normal file
68
app/services/leaderboard_page_cache.rb
Normal 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
|
||||
|
|
@ -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 %>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
14
bun.lock
14
bun.lock
|
|
@ -230,6 +230,8 @@
|
|||
|
||||
"@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=="],
|
||||
|
||||
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||
|
|
@ -242,13 +244,13 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -350,7 +352,7 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -540,7 +542,7 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -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-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=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -117,9 +117,7 @@ Rails.application.routes.draw do
|
|||
|
||||
get "/leaderboard", to: redirect("/leaderboards", status: 301)
|
||||
|
||||
resources :leaderboards, only: [ :index ] do
|
||||
get :entries, on: :collection
|
||||
end
|
||||
resources :leaderboards, only: [ :index ]
|
||||
|
||||
# Docs routes
|
||||
# Note: llms.txt and llms-full.txt are served as static files from public/
|
||||
|
|
|
|||
|
|
@ -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
3
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# 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
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
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 "deleted_at"
|
||||
t.datetime "finished_generating_at"
|
||||
t.integer "generation_duration_seconds"
|
||||
t.integer "period_type", default: 0, null: false
|
||||
t.date "start_date", null: false
|
||||
t.integer "timezone_offset"
|
||||
|
|
|
|||
|
|
@ -16,18 +16,18 @@
|
|||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"axios": "^1.13.6",
|
||||
"bits-ui": "^2.16.3",
|
||||
"axios": "^1.14.0",
|
||||
"bits-ui": "^2.16.4",
|
||||
"d3-scale": "^4.0.2",
|
||||
"layerchart": "^1.0.13",
|
||||
"plur": "^6.0.0",
|
||||
"svelte": "^5.54.0",
|
||||
"svelte": "^5.55.1",
|
||||
"svelte-check": "^4.4.5",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-ruby": "^5.2.0"
|
||||
"vite-plugin-ruby": "^5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.8.1",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class LeaderboardsControllerTest < ActionDispatch::IntegrationTest
|
|||
Rails.cache.clear
|
||||
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")
|
||||
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")
|
||||
|
||||
assert_response :success
|
||||
assert_select "a[href='#{leaderboards_path(period_type: "last_7_days", scope: "global")}']", text: "Global"
|
||||
assert_select "a[href='#{leaderboards_path(period_type: "last_7_days", scope: "country")}']", text: /United States/
|
||||
assert_select "a[href='#{leaderboards_path(period_type: "daily", scope: "country")}']", text: "Last 24 Hours"
|
||||
assert_inertia_component "Leaderboards/Index"
|
||||
assert_inertia_prop "period_type", "last_7_days"
|
||||
assert_inertia_prop "scope", "country"
|
||||
page = inertia_page
|
||||
assert_equal "US", page.dig("props", "country", "code")
|
||||
assert page.dig("props", "country", "available")
|
||||
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")
|
||||
create_boards_for_today(period_type: :daily)
|
||||
|
||||
|
|
@ -30,30 +33,21 @@ class LeaderboardsControllerTest < ActionDispatch::IntegrationTest
|
|||
get leaderboards_path(period_type: "daily", scope: "country")
|
||||
|
||||
assert_response :success
|
||||
assert_select "span", text: "Country"
|
||||
assert_select "a[href='#{leaderboards_path(period_type: "daily", scope: "global")}']"
|
||||
assert_inertia_component "Leaderboards/Index"
|
||||
assert_inertia_prop "scope", "global"
|
||||
page = inertia_page
|
||||
assert_not page.dig("props", "country", "available")
|
||||
end
|
||||
|
||||
test "entries clamps page=0 to page 1 instead of erroring" do
|
||||
user = create_user(username: "page_zero_user")
|
||||
board = create_boards_for_today(period_type: :daily).first
|
||||
board.entries.create!(user: user, total_seconds: 100)
|
||||
test "index clamps invalid period_type to daily" do
|
||||
user = create_user(username: "bad_period_user2")
|
||||
create_boards_for_today(period_type: :daily)
|
||||
|
||||
sign_in_as(user)
|
||||
get entries_leaderboards_path(period_type: "daily", page: 0), xhr: true
|
||||
|
||||
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
|
||||
get leaderboards_path(period_type: "bogus")
|
||||
|
||||
assert_response :success
|
||||
assert_inertia_prop "period_type", "daily"
|
||||
end
|
||||
|
||||
test "validated_period_type does not intern arbitrary symbols" do
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ class LeaderboardUpdateJobTest < ActiveJob::TestCase
|
|||
|
||||
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_operator board.generation_duration_seconds, :>=, 1
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
63
test/services/leaderboard_page_cache_test.rb
Normal file
63
test/services/leaderboard_page_cache_test.rb
Normal 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
|
||||
Loading…
Add table
Reference in a new issue