mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 22:15:14 +00:00
remove scrapyard leaderboards
This commit is contained in:
parent
509f763e13
commit
9b4f764c33
10 changed files with 0 additions and 589 deletions
|
|
@ -1,18 +0,0 @@
|
|||
.scrapyard-notice {
|
||||
background-color: rgba(var(--uchu-green-rgb), 0.2);
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--uchu-green);
|
||||
color: var(--uchu-yellow) !important;
|
||||
position: relative;
|
||||
padding: 1rem;
|
||||
margin-top: 3rem;
|
||||
max-width: 26em;
|
||||
}
|
||||
|
||||
.scrapyard-notice img {
|
||||
position: absolute;
|
||||
max-width: 100px;
|
||||
max-height: 100px;
|
||||
top: -50px;
|
||||
right: 20px;
|
||||
}
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
/* Common styles */
|
||||
.scrapyard-leaderboard,
|
||||
.scrapyard-event {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.scrapyard-leaderboard h1,
|
||||
.scrapyard-event h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Index page styles */
|
||||
.sort-controls {
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sort-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.sort-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sort-link.active {
|
||||
opacity: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
background: var(--uchu-light-green);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.event-card.highlighted {
|
||||
border: 2px solid #FFE066;
|
||||
background: #f4f9f4;
|
||||
box-shadow: 0 0 10px rgba(255, 224, 102, 0.3);
|
||||
}
|
||||
|
||||
.pin-link {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
text-decoration: none;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.pin-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.ordinal {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.7;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.stat .average {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
font-weight: normal;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.stat .no-time {
|
||||
opacity: 0.7;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.active-sort {
|
||||
font-weight: bold !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Show page styles */
|
||||
.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;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
background: rgba(var(--uchu-green-rgb), 0.1);
|
||||
border-color: rgba(var(--uchu-green-rgb), 0.2);
|
||||
}
|
||||
|
||||
.event-card.highlighted {
|
||||
background: rgba(var(--uchu-green-rgb), 0.15);
|
||||
border-color: var(--uchu-yellow);
|
||||
box-shadow: 0 0 10px rgba(var(--uchu-yellow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.event-card h2 a:hover {
|
||||
color: var(--uchu-yellow);
|
||||
}
|
||||
|
||||
.attendee-leaderboard {
|
||||
background: rgba(var(--uchu-green-rgb), 0.1);
|
||||
border: 1px solid rgba(var(--uchu-green-rgb), 0.2);
|
||||
}
|
||||
|
||||
.attendee-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.stat label,
|
||||
.time,
|
||||
.ordinal {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.stat .average,
|
||||
.stat .no-time {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.back-link,
|
||||
.sort-link {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.back-link:hover,
|
||||
.sort-link:hover,
|
||||
.sort-link.active {
|
||||
color: var(--uchu-yellow);
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
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
|
||||
TRACKING_END_TIME = Time.find_zone("Eastern Time (US & Canada)").local(2025, 3, 17, 20, 0).to_i
|
||||
PINNED_EVENT_TIMEOUT = 1.minutes
|
||||
|
||||
helper_method :is_watched?
|
||||
|
||||
def index
|
||||
@sort_by = params[:sort] == "average" ? "average" : "total"
|
||||
|
||||
# If there's a pinned event, cache it
|
||||
if params[:event_pin].present?
|
||||
mark_event_as_pinned(params[:event_pin])
|
||||
end
|
||||
|
||||
# Cache the expensive computations for 10 seconds
|
||||
@event_stats = Rails.cache.fetch("scrapyard_leaderboard_stats", expires_in: 10.seconds) do
|
||||
# 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)
|
||||
.where("time <= ?", TRACKING_END_TIME)
|
||||
.group(:user_id)
|
||||
.duration_seconds
|
||||
.values
|
||||
.sum
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
{
|
||||
event: event,
|
||||
total_seconds: total_seconds,
|
||||
hackatime_users: users.count,
|
||||
total_attendees: attendees.count,
|
||||
average_seconds_per_attendee: users.any? ? (total_seconds.to_f / users.count) : 0
|
||||
}
|
||||
end
|
||||
|
||||
# filter out events with no users
|
||||
event_stats.select { |stats| stats[:hackatime_users] > 0 }
|
||||
end
|
||||
|
||||
# Sort by selected metric (do this outside cache since it depends on params)
|
||||
@event_stats = @event_stats.sort_by do |stats|
|
||||
if @sort_by == "average"
|
||||
-stats[:average_seconds_per_attendee]
|
||||
else
|
||||
-stats[:total_seconds]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def pin
|
||||
event_id = params[:id]
|
||||
mark_event_as_pinned(event_id)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def show
|
||||
@event = Warehouse::ScrapyardEvent.find(params[:id])
|
||||
|
||||
@attendee_stats = Rails.cache.fetch("scrapyard_leaderboard_event_#{@event.id}", expires_in: 10.seconds) do
|
||||
attendees = Warehouse::ScrapyardLocalAttendee
|
||||
.where.not(email: nil)
|
||||
.for_event(@event)
|
||||
.uniq { |attendee| attendee.email }
|
||||
|
||||
emails = attendees.map(&:email).compact
|
||||
email_to_user = EmailAddress.where(email: emails).includes(:user).index_by(&:email)
|
||||
|
||||
user_attendees = attendees.map do |attendee|
|
||||
{
|
||||
user: email_to_user[attendee.email]&.user,
|
||||
attendee: attendee
|
||||
}
|
||||
end.select { |ua| ua[:user].present? }
|
||||
|
||||
users = user_attendees.map { |ua| ua[:user] }
|
||||
user_heartbeats = Heartbeat
|
||||
.where(user: users)
|
||||
.where("time >= ?", TRACKING_START_TIME)
|
||||
.where("time <= ?", TRACKING_END_TIME)
|
||||
.group(:user_id)
|
||||
.duration_seconds
|
||||
|
||||
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
|
||||
|
||||
stats.sort_by { |stats| -stats[:total_seconds] }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mark_event_as_pinned(event_id)
|
||||
Rails.cache.write(
|
||||
"pinned_event:#{event_id}",
|
||||
true,
|
||||
expires_in: PINNED_EVENT_TIMEOUT
|
||||
)
|
||||
end
|
||||
|
||||
def is_watched?(event_id)
|
||||
Rails.cache.exist?("pinned_event:#{event_id}")
|
||||
end
|
||||
end
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
<div class="scrapyard-leaderboard">
|
||||
<h1>Scrapyard Leaderboard</h1>
|
||||
|
||||
<div class="sort-controls">
|
||||
Sort by:
|
||||
<%= link_to "Total Time", scrapyard_leaderboards_path(sort: 'total'),
|
||||
class: "sort-link #{@sort_by == 'total' ? 'active' : ''}" %>
|
||||
|
|
||||
<%= link_to "Average Time", scrapyard_leaderboards_path(sort: 'average'),
|
||||
class: "sort-link #{@sort_by == 'average' ? 'active' : ''}" %>
|
||||
</div>
|
||||
|
||||
<% @event_stats.each_with_index do |stats, index| %>
|
||||
<div class="event-card" id="event-<%= stats[:event].id %>">
|
||||
<h2>
|
||||
<%= link_to scrapyard_leaderboard_path(stats[:event]) do %>
|
||||
<% case index %>
|
||||
<% when 0 %>
|
||||
🥇
|
||||
<% when 1 %>
|
||||
🥈
|
||||
<% when 2 %>
|
||||
🥉
|
||||
<% else %>
|
||||
<span class="ordinal"><%= index + 1 %></span>
|
||||
<% end %>
|
||||
<%= stats[:event].name %>
|
||||
<% if is_watched?(stats[:event].id) %>
|
||||
<span title="Currently being watched">👀</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</h2>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<label>Coding time</label>
|
||||
<span>
|
||||
<% if stats[:total_seconds] > 0 %>
|
||||
<span class="<%= @sort_by == 'total' ? 'active-sort' : '' %>">
|
||||
<%= short_time_detailed(stats[:total_seconds]) %>
|
||||
</span>
|
||||
<span class="average <%= @sort_by == 'average' ? 'active-sort' : '' %>">
|
||||
(<%= short_time_detailed(stats[:average_seconds_per_attendee]) %> avg)
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="no-time">No time logged</span>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<label>Hackatime users / Estimated attendees</label>
|
||||
<span>
|
||||
<%= stats[:hackatime_users] %>/<%= stats[:total_attendees] %>
|
||||
<% percent = (stats[:hackatime_users].to_f / stats[:total_attendees] * 100) %>
|
||||
(<%= percent < 1 ? "%.1f" % percent : percent.to_i %>%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% content_for :head do %>
|
||||
<script>
|
||||
function handlePin() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const pinId = params.get('event_pin');
|
||||
|
||||
if (pinId) {
|
||||
const targetCard = document.getElementById('event-' + pinId);
|
||||
if (targetCard) {
|
||||
// Remove any existing highlights
|
||||
document.querySelectorAll('.event-card.highlighted').forEach(card => {
|
||||
card.classList.remove('highlighted');
|
||||
});
|
||||
|
||||
targetCard.classList.add('highlighted');
|
||||
// Center the card
|
||||
const windowHeight = window.innerHeight;
|
||||
const cardHeight = targetCard.offsetHeight;
|
||||
const cardTop = targetCard.offsetTop;
|
||||
const scrollTo = cardTop - (windowHeight - cardHeight) / 2;
|
||||
window.scrollTo({ top: scrollTo });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle both initial page load and subsequent Turbo navigations
|
||||
document.addEventListener('turbo:load', handlePin);
|
||||
document.addEventListener('turbo:render', handlePin);
|
||||
document.addEventListener('DOMContentLoaded', handlePin);
|
||||
|
||||
// Set up auto-refresh if we have a pin
|
||||
document.addEventListener('turbo:load', function() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has('event_pin')) {
|
||||
// Clear any existing refresh interval
|
||||
if (window.refreshInterval) {
|
||||
clearInterval(window.refreshInterval);
|
||||
}
|
||||
|
||||
// Set up new refresh using Turbo
|
||||
window.refreshInterval = setInterval(() => {
|
||||
Turbo.visit(window.location.href, { action: 'replace' });
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up interval when leaving the page
|
||||
document.addEventListener('turbo:before-visit', function() {
|
||||
if (window.refreshInterval) {
|
||||
clearInterval(window.refreshInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<% end %>
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<div class="scrapyard-event">
|
||||
<div class="header">
|
||||
<h1><%= @event.name %></h1>
|
||||
<%= link_to "← Back to all scrapyards", scrapyard_leaderboards_path, class: "back-link" %>
|
||||
</div>
|
||||
|
||||
<div class="attendee-leaderboard">
|
||||
<h2>Hackatime Users</h2>
|
||||
|
||||
<% if @attendee_stats.any? %>
|
||||
<div class="attendees">
|
||||
<% @attendee_stats.each_with_index do |stats, index| %>
|
||||
<div class="attendee-card">
|
||||
<div class="info">
|
||||
<div class="name">
|
||||
<%= stats[:display_name] %>
|
||||
</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 %>
|
||||
<% end %>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<div class="scrapyard-notice">
|
||||
<img src="https://hc-cdn.hel1.your-objectstorage.com/s/v3/936b246fe5593e09ceed33d04132212223241e71_hourglass.png">
|
||||
<h2>Went to Scrapyard?</h2>
|
||||
<p>The event is over, but you can still see the <%= link_to "leaderboards", scrapyard_leaderboards_path %>!</p>
|
||||
</div>
|
||||
|
|
@ -159,8 +159,6 @@ Rails.application.routes.draw do
|
|||
end
|
||||
end
|
||||
|
||||
resources :scrapyard_leaderboards, only: [ :index, :show ]
|
||||
|
||||
# SEO routes
|
||||
get "/sitemap.xml", to: "sitemap#sitemap", defaults: { format: "xml" }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ ignored_endpoints:
|
|||
- SessionsController#impersonate
|
||||
- SessionsController#stop_impersonating
|
||||
- LetterOpenerWeb::Engine
|
||||
- ScrapyardLeaderboardsController#index
|
||||
- Api::Hackatime::V1::HackatimeController#push_heartbeats
|
||||
- Api::Hackatime::V1::HackatimeController#status_bar_today
|
||||
- Api::V1::StatsController#user_stats
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue