diff --git a/app/assets/fonts/Bold.woff b/app/assets/fonts/Bold.woff new file mode 100644 index 0000000..4dc84c4 Binary files /dev/null and b/app/assets/fonts/Bold.woff differ diff --git a/app/assets/fonts/Bold.woff2 b/app/assets/fonts/Bold.woff2 new file mode 100644 index 0000000..8c00084 Binary files /dev/null and b/app/assets/fonts/Bold.woff2 differ diff --git a/app/assets/fonts/Italic.woff b/app/assets/fonts/Italic.woff new file mode 100644 index 0000000..9068cbc Binary files /dev/null and b/app/assets/fonts/Italic.woff differ diff --git a/app/assets/fonts/Italic.woff2 b/app/assets/fonts/Italic.woff2 new file mode 100644 index 0000000..056909c Binary files /dev/null and b/app/assets/fonts/Italic.woff2 differ diff --git a/app/assets/fonts/Regular.woff b/app/assets/fonts/Regular.woff new file mode 100644 index 0000000..ccfda8c Binary files /dev/null and b/app/assets/fonts/Regular.woff differ diff --git a/app/assets/fonts/Regular.woff2 b/app/assets/fonts/Regular.woff2 new file mode 100644 index 0000000..d7c3d52 Binary files /dev/null and b/app/assets/fonts/Regular.woff2 differ diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 7471403..3b0d991 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -9,9 +9,50 @@ * Consider organizing styles into separate files for maintainability. */ +/* Phantom Sans Font Faces */ +@font-face { + font-family: 'Phantom Sans'; + src: url('/assets/Regular.woff2') format('woff2'), + url('/assets/Regular.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Phantom Sans'; + src: url('/assets/Italic.woff2') format('woff2'), + url('/assets/Italic.woff') format('woff'); + font-weight: normal; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Phantom Sans'; + src: url('/assets/Bold.woff2') format('woff2'), + url('/assets/Bold.woff') format('woff'); + font-weight: bold; + font-style: normal; + font-display: swap; +} + @import "https://uchu.style/color.css"; @import "settings.css"; +body, html { + font-family: 'Phantom Sans', 'Segoe UI', sans-serif; +} + +*, *::before, *::after { + font-family: inherit; +} + +code, pre, kbd, samp { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; +} + + /* Force dark mode for all elements */ *, *::before, *::after { color-scheme: dark; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2f42bd7..c10008e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base before_action :honeybadger_context, if: :current_user before_action :initialize_cache_counters before_action :try_rack_mini_profiler_enable + before_action :track_request after_action :track_action around_action :switch_time_zone, if: :current_user @@ -30,6 +31,10 @@ class ApplicationController < ActionController::Base ahoy.track "Ran action", request.path_parameters end + def track_request + RequestCounter.increment + end + def try_rack_mini_profiler_enable if current_user && current_user.is_admin? Rack::MiniProfiler.authorize_request diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index a2d3330..2657873 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -92,6 +92,10 @@ class StaticPagesController < ApplicationController @leaderboard = LeaderboardGenerator.generate_timezone_offset_leaderboard( Date.current, current_user.timezone_utc_offset, :daily ) + + if @leaderboard&.entries&.empty? + Rails.logger.warn "[MiniLeaderboard] Regional leaderboard empty for offset #{current_user.timezone_utc_offset}" + end else # Use global leaderboard @leaderboard = Leaderboard.where.associated(:entries) @@ -102,6 +106,16 @@ class StaticPagesController < ApplicationController .first end + if @leaderboard.nil? || @leaderboard.entries.empty? + Rails.logger.info "[MiniLeaderboard] Falling back to global leaderboard" + @leaderboard = Leaderboard.where.associated(:entries) + .where(start_date: Date.current) + .where(deleted_at: nil) + .where(period_type: :daily) + .distinct + .first + end + @active_projects = Cache::ActiveProjectsJob.perform_now render partial: "leaderboards/mini_leaderboard", locals: { diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6653306..b75902b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -5,6 +5,11 @@ module ApplicationHelper { hits: hits, misses: misses } end + def requests_per_second + rps = RequestCounter.per_second + rps == :high_load ? "lots of req/sec" : "#{rps} req/sec" + end + def admin_tool(class_name = "", element = "div", **options, &block) return unless current_user&.is_admin? concat content_tag(element, class: "admin-tool #{class_name}", **options, &block) diff --git a/app/jobs/warm_mini_leaderboard_cache_job.rb b/app/jobs/warm_mini_leaderboard_cache_job.rb new file mode 100644 index 0000000..28e5b0c --- /dev/null +++ b/app/jobs/warm_mini_leaderboard_cache_job.rb @@ -0,0 +1,20 @@ +class WarmMiniLeaderboardCacheJob < ApplicationJob + queue_as :default + + def perform + offsets = [ -8, -7, -6, -5, -4, -3, 0, 1, 2, 8, 9, 10, 11, 12 ] + + offsets.each do |offset| + begin + LeaderboardGenerator.generate_timezone_offset_leaderboard( + Date.current, + offset, + :daily + ) + Rails.logger.info "Warmed mini leaderboard cache for UTC#{offset >= 0 ? '+' : ''}#{offset}" + rescue => e + Rails.logger.error "Failed to warm cache for UTC#{offset >= 0 ? '+' : ''}#{offset}: #{e.message}" + end + end + end +end diff --git a/app/lib/request_counter.rb b/app/lib/request_counter.rb new file mode 100644 index 0000000..b42934c --- /dev/null +++ b/app/lib/request_counter.rb @@ -0,0 +1,57 @@ +class RequestCounter + WINDOW_SIZE = 10 # seconds - shorter window for more responsive rates + HIGH_LOAD_THRESHOLD = 500 # req/sec to disable tracking + CIRCUIT_BREAKER_DURATION = 30 # seconds to stay disabled + + @buckets = {} + @disabled_until = nil + + class << self + def increment + return if disabled? + + current_time = Time.current.to_i + @buckets[current_time] = (@buckets[current_time] || 0) + 1 + + # Check if we should disable due to high load + check_circuit_breaker(current_time) + + # Periodically clean old buckets (1% chance) + cleanup if rand(100) == 0 + end + + def per_second + return :high_load if disabled? + + current_time = Time.current.to_i + cutoff = current_time - WINDOW_SIZE + + total = @buckets.select { |timestamp, _| timestamp >= cutoff }.values.sum + (total.to_f / WINDOW_SIZE).round(2) + end + + private + + def disabled? + @disabled_until && Time.current.to_i < @disabled_until + end + + def check_circuit_breaker(current_time) + # Check last 5 seconds for high load + recent_total = @buckets.select { |ts, _| ts >= current_time - 5 }.values.sum + + if recent_total > HIGH_LOAD_THRESHOLD * 5 # 5 seconds worth + @disabled_until = current_time + CIRCUIT_BREAKER_DURATION + @buckets.clear # Clear to reduce memory + end + end + + def cleanup + return if disabled? # Skip cleanup when disabled + + current_time = Time.current.to_i + cutoff = current_time - WINDOW_SIZE - 10 # extra buffer + @buckets.reject! { |timestamp, _| timestamp < cutoff } + end + end +end diff --git a/app/models/concerns/heartbeatable.rb b/app/models/concerns/heartbeatable.rb index 3b6a694..b85e585 100644 --- a/app/models/concerns/heartbeatable.rb +++ b/app/models/concerns/heartbeatable.rb @@ -98,9 +98,19 @@ module Heartbeatable end def daily_streaks_for_users(user_ids, start_date: 31.days.ago) - # First get the raw durations using window function + return {} if user_ids.empty? + start_date = [ start_date, 30.days.ago ].max + keys = user_ids.map { |id| "user_streak_#{id}" } + streak_cache = Rails.cache.read_multi(*keys) + + uncached_users = user_ids.select { |id| streak_cache["user_streak_#{id}"].nil? } + + if uncached_users.empty? + return user_ids.index_with { |id| streak_cache["user_streak_#{id}"] || 0 } + end + raw_durations = joins(:user) - .where(user_id: user_ids) + .where(user_id: uncached_users) .coding_only .with_valid_timestamps .where(time: start_date..Time.current) @@ -128,8 +138,7 @@ module Heartbeatable } end - # Initialize the result hash with zeros for all users - result = user_ids.index_with { 0 } + result = user_ids.index_with { |id| streak_cache["user_streak_#{id}"] || 0 } # Then calculate streaks for each user daily_durations.each do |user_id, data| @@ -158,6 +167,9 @@ module Heartbeatable end result[user_id] = streak + + # Cache the streak for 1 hour + Rails.cache.write("user_streak_#{user_id}", streak, expires_in: 1.hour) end result diff --git a/app/services/leaderboard_generator.rb b/app/services/leaderboard_generator.rb index 9ab4959..99a56f7 100644 --- a/app/services/leaderboard_generator.rb +++ b/app/services/leaderboard_generator.rb @@ -11,14 +11,24 @@ class LeaderboardGenerator def generate_timezone_offset_leaderboard(date, utc_offset, period_type = :daily) date = Date.current if date.blank? - users = User.users_in_timezone_offset(utc_offset).not_convicted - generate_leaderboard_for_users(users, date, "UTC#{utc_offset >= 0 ? '+' : ''}#{utc_offset}", period_type) + + cache_key = "timezone_leaderboard_#{utc_offset}_#{date}_#{period_type}" + + Rails.cache.fetch(cache_key, expires_in: 5.minutes) do + users = User.users_in_timezone_offset(utc_offset).not_convicted + generate_leaderboard_for_users(users, date, "UTC#{utc_offset >= 0 ? '+' : ''}#{utc_offset}", period_type) + end end def generate_timezone_leaderboard(date, timezone, period_type = :daily) date = Date.current if date.blank? - users = User.users_in_timezone(timezone).not_convicted - generate_leaderboard_for_users(users, date, timezone, period_type) + + cache_key = "timezone_leaderboard_#{timezone.gsub('/', '_')}_#{date}_#{period_type}" + + Rails.cache.fetch(cache_key, expires_in: 5.minutes) do + users = User.users_in_timezone(timezone).not_convicted + generate_leaderboard_for_users(users, date, timezone, period_type) + end end private @@ -34,10 +44,13 @@ class LeaderboardGenerator finished_generating_at: Time.current ) - # Get user IDs + # Get user IDs and preload users hash for faster lookups user_ids = users.pluck(:id) return leaderboard if user_ids.empty? + # Preload users into a hash for O(1) lookups + users_hash = users.index_by(&:id) + # Calculate heartbeats for the date range in UTC date_range = case period_type when :weekly @@ -48,7 +61,7 @@ class LeaderboardGenerator date.all_day end - # Get heartbeats for these users + # Get heartbeats for these users - limit to reduce query size heartbeats = Heartbeat.where(user_id: user_ids, time: date_range) .coding_only .with_valid_timestamps @@ -59,8 +72,10 @@ class LeaderboardGenerator user_totals = heartbeats.group(:user_id).duration_seconds user_totals = user_totals.filter { |_, total_seconds| total_seconds > 60 } - # Get streaks for all users at once - streaks = Heartbeat.daily_streaks_for_users(user_totals.keys) if user_totals.any? + # Only calculate streaks for users who actually have time today + # This significantly reduces the streak calculation overhead + streak_user_ids = user_totals.keys + streaks = streak_user_ids.any? ? Heartbeat.daily_streaks_for_users(streak_user_ids, start_date: 30.days.ago) : {} # Create virtual leaderboard entries entries = user_totals.map do |user_id, total_seconds| @@ -71,8 +86,8 @@ class LeaderboardGenerator streak_count: streaks[user_id] || 0 ) - # Manually set the user association to avoid N+1 queries - entry.user = users.find { |u| u.id == user_id } + # Use preloaded users hash instead of find + entry.user = users_hash[user_id] entry end.sort_by(&:total_seconds).reverse diff --git a/app/views/admin/post_reviews/show.html.erb b/app/views/admin/post_reviews/show.html.erb index 5c8a79f..6ff54d6 100644 --- a/app/views/admin/post_reviews/show.html.erb +++ b/app/views/admin/post_reviews/show.html.erb @@ -16,7 +16,6 @@