mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 19:55:16 +00:00
Add initial scrapyard leaderboard
This commit is contained in:
parent
6df1f10109
commit
92d37800f6
10 changed files with 350 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
100
app/controllers/scrapyard_leaderboards_controller.rb
Normal file
100
app/controllers/scrapyard_leaderboards_controller.rb
Normal 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
|
||||
9
app/models/warehouse/scrapyard_event.rb
Normal file
9
app/models/warehouse/scrapyard_event.rb
Normal 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
|
||||
25
app/models/warehouse/scrapyard_local_attendee.rb
Normal file
25
app/models/warehouse/scrapyard_local_attendee.rb
Normal 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
|
||||
4
app/models/warehouse_record.rb
Normal file
4
app/models/warehouse_record.rb
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
class WarehouseRecord < ApplicationRecord
|
||||
self.abstract_class = true
|
||||
connects_to database: { reading: :warehouse, writing: :warehouse }
|
||||
end
|
||||
74
app/views/scrapyard_leaderboards/index.html.erb
Normal file
74
app/views/scrapyard_leaderboards/index.html.erb
Normal 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 %>
|
||||
118
app/views/scrapyard_leaderboards/show.html.erb
Normal file
118
app/views/scrapyard_leaderboards/show.html.erb
Normal 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 %>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -83,4 +83,6 @@ Rails.application.routes.draw do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
resources :scrapyard_leaderboards, only: [ :index, :show ]
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue