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
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

View file

@ -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)

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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>;

View file

@ -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";

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">
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>

View file

@ -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
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
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"

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=="],
"@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=="],

View file

@ -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/

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.
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"

View file

@ -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",

View file

@ -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

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 120, board.entries.find_by!(user_id: coded_user.id).total_seconds
assert_operator board.generation_duration_seconds, :>=, 1
end
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