Add initial scrapyard leaderboard

This commit is contained in:
Max Wofford 2025-03-15 01:01:45 -04:00
parent 6df1f10109
commit 92d37800f6
10 changed files with 350 additions and 0 deletions

View file

@ -14,6 +14,9 @@ TELETYPE_API_KEY=your_teletype_api_key_here
# Wakatime database url used for migrating data from waka.hackclub.com
WAKATIME_DATABASE_URL=your_wakatime_database_url_here
# Data warehouse for metabase
WAREHOUSE_DATABASE_URL=your_warehouse_database_url_here
# You can leave this alone if you're using the provided docker setup!
DATABASE_URL=your_database_url_here

View file

@ -0,0 +1,100 @@
class ScrapyardLeaderboardsController < ApplicationController
# March 14, 2024 at 8:00 PM Eastern
TRACKING_START_TIME = Time.find_zone("Eastern Time (US & Canada)").local(2025, 3, 14, 20, 0).to_i
def index
# Get all attendees and their emails in one query
event_attendees = Warehouse::ScrapyardLocalAttendee
.where.not(email: nil)
.group_by(&:event_id)
# Only get events that have attendees
@events = Warehouse::ScrapyardEvent
.where(id: event_attendees.keys)
.order(created_at: :desc)
# Pre-fetch all users for the emails we have
all_emails = event_attendees.values.flatten.map(&:email).compact
email_to_user = EmailAddress.where(email: all_emails).includes(:user).index_by(&:email)
# For each event, get the total coding time of its attendees
@event_stats = @events.map do |event|
# Get attendees for this event
attendees = event_attendees[event.id] || []
users = attendees
.map { |a| email_to_user[a.email]&.user }
.compact
.uniq
# Calculate total duration for all users in one query
total_seconds = if users.any?
Heartbeat
.where(user: users)
.where("time >= ?", TRACKING_START_TIME)
.duration_seconds
else
0
end
{
event: event,
total_seconds: total_seconds,
attendee_count: users.count,
average_seconds_per_attendee: users.any? ? (total_seconds.to_f / users.count) : 0
}
end
# filter out events with no users
@event_stats = @event_stats.select { |stats| stats[:attendee_count] > 0 }
# Sort by total coding time
@event_stats = @event_stats.sort_by { |stats| -stats[:total_seconds] }
end
def show
@event = Warehouse::ScrapyardEvent.find(params[:id])
# Get attendees and their emails
attendees = Warehouse::ScrapyardLocalAttendee
.where.not(email: nil)
.for_event(@event)
.uniq { |attendee| attendee.email }
# Pre-fetch all users for the emails we have
emails = attendees.map(&:email).compact
email_to_user = EmailAddress.where(email: emails).includes(:user).index_by(&:email)
# Create a map of email to attendee for looking up preferred names
email_to_attendee = attendees.index_by(&:email)
# Map attendees to users while keeping track of the original attendee
user_attendees = attendees.map do |attendee|
{
user: email_to_user[attendee.email]&.user,
attendee: attendee
}
end.select { |ua| ua[:user].present? }
# Get all heartbeats for all users in one query
users = user_attendees.map { |ua| ua[:user] }
user_heartbeats = Heartbeat
.where(user: users)
.where("time >= ?", TRACKING_START_TIME)
.group(:user_id)
.duration_seconds
# Map the results
@attendee_stats = user_attendees.map do |ua|
email = ua[:attendee].email if Rails.env.development?
{
user: ua[:user],
display_name: ua[:user].username || ua[:attendee].preferred_name || "Anonymous",
total_seconds: user_heartbeats[ua[:user].id] || 0,
email: email || nil
}
end
# Sort by total coding time
@attendee_stats = @attendee_stats.sort_by { |stats| -stats[:total_seconds] }
end
end

View file

@ -0,0 +1,9 @@
class Warehouse::ScrapyardEvent < WarehouseRecord
self.table_name = "airtable_hack_club_scrapyard_appigkif7gbvisalg.events"
# Prevent these columns from messing with acriverecord
self.ignored_columns += [ "errors" ]
# local attendees is a list of airtable ids
has_many :local_attendees, class_name: "Warehouse::ScrapyardLocalAttendee", foreign_key: "event_id"
end

View file

@ -0,0 +1,25 @@
class Warehouse::ScrapyardLocalAttendee < WarehouseRecord
self.table_name = "airtable_hack_club_scrapyard_appigkif7gbvisalg.local_attendees"
# The event field in Airtable is a JSONB array with a single event ID
def event_id
self[:event]&.first
end
# Override the event association to handle the array field
def event
return nil if event_id.nil?
Warehouse::ScrapyardEvent.find_by(id: event_id)
end
# Find the associated user through their email
def user
return nil if self[:email].blank?
EmailAddress.find_by(email: self[:email])&.user
end
# Scope to find attendees for a specific event
scope :for_event, ->(event) {
where("event ? :event_id", event_id: event.id)
}
end

View file

@ -0,0 +1,4 @@
class WarehouseRecord < ApplicationRecord
self.abstract_class = true
connects_to database: { reading: :warehouse, writing: :warehouse }
end

View file

@ -0,0 +1,74 @@
<h1>Scrapyard Event Leaderboard</h1>
<div class="scrapyard-leaderboard">
<% @event_stats.each do |stats| %>
<div class="event-card">
<h2><%= link_to stats[:event].name, scrapyard_leaderboard_path(stats[:event]) %></h2>
<div class="stats">
<div class="stat">
<label>Total Coding Time:</label>
<span><%= ApplicationController.helpers.short_time_simple(stats[:total_seconds]) %></span>
</div>
<div class="stat">
<label>Attendees:</label>
<span><%= stats[:attendee_count] %></span>
</div>
<div class="stat">
<label>Average Time per Attendee:</label>
<span><%= ApplicationController.helpers.short_time_detailed(stats[:average_seconds_per_attendee]) %></span>
</div>
</div>
</div>
<% end %>
</div>
<% content_for :head do %>
<style>
.scrapyard-leaderboard {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.event-card {
background: var(--uchu-light-green);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.event-card h2 {
margin: 0 0 15px 0;
}
.event-card h2 a {
color: inherit;
text-decoration: none;
}
.event-card h2 a:hover {
text-decoration: underline;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.stat {
display: flex;
flex-direction: column;
}
.stat label {
font-size: 0.9em;
opacity: 0.8;
}
.stat span {
font-size: 1.2em;
font-weight: bold;
}
</style>
<% end %>

View file

@ -0,0 +1,118 @@
<div class="scrapyard-event">
<div class="header">
<h1><%= @event.name %></h1>
<%= link_to "← Back to All Events", scrapyard_leaderboards_path, class: "back-link" %>
</div>
<div class="attendee-leaderboard">
<h2>Attendee Leaderboard</h2>
<% if @attendee_stats.any? %>
<div class="attendees">
<% @attendee_stats.each_with_index do |stats, index| %>
<div class="attendee-card">
<div class="rank"><%= index + 1 %></div>
<div class="info">
<div class="name">
<%= stats[:display_name] %>
<% if Rails.env.development? %>
<%= stats[:email] %>
<% end %>
</div>
<div class="time">
<%= ApplicationController.helpers.short_time_simple(stats[:total_seconds]) %>
</div>
</div>
</div>
<% end %>
</div>
<% else %>
<p class="no-data">No coding activity recorded for this event yet.</p>
<% end %>
</div>
</div>
<% content_for :head do %>
<style>
.scrapyard-event {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.header {
margin-bottom: 30px;
}
.header h1 {
margin: 0 0 10px 0;
}
.back-link {
color: inherit;
text-decoration: none;
opacity: 0.7;
}
.back-link:hover {
opacity: 1;
}
.attendee-leaderboard {
background: var(--uchu-light-green);
border-radius: 8px;
padding: 20px;
}
.attendee-leaderboard h2 {
margin: 0 0 20px 0;
}
.attendees {
display: flex;
flex-direction: column;
gap: 10px;
}
.attendee-card {
display: flex;
align-items: center;
gap: 15px;
padding: 10px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
.rank {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.1);
border-radius: 50%;
font-weight: bold;
}
.info {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.name {
font-weight: bold;
}
.time {
opacity: 0.8;
}
.no-data {
text-align: center;
padding: 20px;
opacity: 0.7;
}
</style>
<% end %>

View file

@ -26,6 +26,11 @@
Home
<% end %>
</li>
<li>
<%= link_to scrapyard_leaderboards_path, class: "nav-item #{current_page?(scrapyard_leaderboards_path) ? 'active' : ''}" do %>
Scrapyard
<% end %>
</li>
<li>
<%= link_to leaderboards_path, class: "nav-item #{current_page?(leaderboards_path) ? 'active' : ''}" do %>
Leaderboards

View file

@ -25,6 +25,11 @@ development:
encoding: unicode
url: <%= ENV['SAILORS_LOG_DATABASE_URL'] %>
replica: true
warehouse:
adapter: postgresql
encoding: unicode
url: <%= ENV['WAREHOUSE_DATABASE_URL'] %>
replica: true
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
@ -60,6 +65,11 @@ production:
encoding: unicode
url: <%= ENV['SAILORS_LOG_DATABASE_URL'] %>
replica: true
warehouse:
adapter: postgresql
encoding: unicode
url: <%= ENV['WAREHOUSE_DATABASE_URL'] %>
replica: true
cache:
<<: *default
adapter: sqlite3

View file

@ -83,4 +83,6 @@ Rails.application.routes.draw do
end
end
end
resources :scrapyard_leaderboards, only: [ :index, :show ]
end