Initial implementation of post_reviews_controller (#247)

This commit is contained in:
Max Wofford
2025-05-22 03:14:27 -04:00
committed by GitHub
parent d05081cd4e
commit 1fba58c6fb
13 changed files with 770 additions and 3 deletions

View File

@@ -0,0 +1,139 @@
# app/controllers/admin/post_reviews_controller.rb
class Admin::PostReviewsController < Admin::BaseController
include ApplicationHelper # For short_time_simple, short_time_detailed helpers
before_action :set_post, only: [ :show ]
def show
# User related to the post
slack_uid = @post.airtable_fields["slackId"]&.first
@user = User.find_by(slack_uid: slack_uid)
ensure_exists @user # Redirects if user not found
@target_user_timezone = @user.timezone || "UTC" # Target user's timezone
@project_repo_mappings_for_user = @user.project_repo_mappings.index_by(&:project_name)
begin
post_start_str = @post.airtable_fields["lastPost"]
post_end_str = @post.airtable_fields["createdAt"]
@total_post_hackatime_seconds = @post.airtable_fields["hackatimeTime"]&.to_i || 0
if post_start_str.blank? || post_end_str.blank?
raise ArgumentError, "Post start or end date is missing from Airtable. lastPost: '#{post_start_str}', createdAt: '#{post_end_str}'"
end
post_start_utc = Time.zone.parse(post_start_str)
post_end_utc = Time.zone.parse(post_end_str)
rescue ArgumentError, TypeError => e
Rails.logger.error "Failed to parse post dates from Airtable for post #{@post.id} (User: #{@user.id}): #{e.message}"
flash.now[:alert] = "Could not parse post dates for post ID #{@post.airtable_id}. Please check Airtable data. Error: #{e.message}"
@review_start_date = Date.current - 1.day
@review_end_date = Date.current + 1.day
@post_start_display = Time.current
@post_end_display = Time.current
@commits = []
@detailed_spans = []
render and return
end
@post_start_display = post_start_utc.in_time_zone(@target_user_timezone)
@post_end_display = post_end_utc.in_time_zone(@target_user_timezone)
@review_start_date = (@post_start_display.to_date - 1.day)
@review_end_date = (@post_end_display.to_date + 1.day)
query_start_utc = @review_start_date.in_time_zone(@target_user_timezone).beginning_of_day.utc
query_end_utc = @review_end_date.in_time_zone(@target_user_timezone).end_of_day.utc
@commits = Commit.where(user: @user, created_at: query_start_utc..query_end_utc).order(created_at: :asc)
all_heartbeats_for_user_in_review_window = Heartbeat
.where.not(project: nil)
.where(user: @user, time: query_start_utc.to_f..query_end_utc.to_f)
.select(:id, :user_id, :time, :entity, :project, :editor, :language)
.order(:time)
.to_a
@unique_project_names = all_heartbeats_for_user_in_review_window
.map(&:project)
.compact
.reject(&:blank?)
.uniq
.sort
@recommended_project_names = @post.app.projects.map(&:airtable_fields).map { |p| p["name"] }.compact.uniq.sort
if params[:projects].present?
selected_projects = params[:projects].split(",")
all_heartbeats_for_user_in_review_window = all_heartbeats_for_user_in_review_window.select do |hb|
selected_projects.include?(hb.project)
end
end
@detailed_spans = []
timeout_duration = 20.minutes.to_i
if all_heartbeats_for_user_in_review_window.any?
current_span_heartbeats = []
all_heartbeats_for_user_in_review_window.each_with_index do |heartbeat, index|
current_span_heartbeats << heartbeat
is_last_heartbeat = (index == all_heartbeats_for_user_in_review_window.length - 1)
time_to_next = is_last_heartbeat ? Float::INFINITY : (all_heartbeats_for_user_in_review_window[index + 1].time - heartbeat.time)
if time_to_next > timeout_duration || is_last_heartbeat
if current_span_heartbeats.any?
start_time_numeric = current_span_heartbeats.first.time
last_hb_time_numeric = current_span_heartbeats.last.time
actual_coded_duration_seconds = Heartbeat.where(id: current_span_heartbeats.map(&:id)).duration_seconds
files = current_span_heartbeats.map { |h| h.entity&.split("/")&.last }.compact.uniq.sort
projects_details_for_span = []
unique_project_names = current_span_heartbeats.map(&:project).compact.reject(&:blank?).uniq
unique_project_names.each do |p_name|
repo_mapping = @project_repo_mappings_for_user[p_name]
projects_details_for_span << {
name: p_name,
repo_url: repo_mapping&.repo_url
}
end
editors = current_span_heartbeats.map(&:editor).compact.uniq.sort
languages = current_span_heartbeats.map(&:language).compact.uniq.sort
@detailed_spans << {
id: "span_#{SecureRandom.hex(4)}", # Unique ID for checkbox
start_time: start_time_numeric,
end_time: last_hb_time_numeric,
duration: actual_coded_duration_seconds,
files_edited: files,
projects_edited_details: projects_details_for_span.sort_by { |p| p[:name].downcase },
editors: editors,
languages: languages
}
current_span_heartbeats = []
end
end
end
end
@current_user_timezone = current_user.timezone
end
private
def set_post
@post = Neighborhood::Post.find_by(airtable_id: params[:post_id])
ensure_exists @post
end
def ensure_exists(value)
unless value.present?
object_name = value.nil? ? "Record" : value.class.name.demodulize
redirect_to admin_timeline_path, alert: "#{object_name} not found."
end
end
end

View File

@@ -0,0 +1,25 @@
class Admin::YswsReviewsController < Admin::BaseController
include ApplicationHelper
before_action :set_submission, only: [ :show ]
def show
end
private
def set_submission
@submission = Neighborhood::YswsSubmission.find_by(airtable_id: params[:record_id])
ensure_exists @submission
@app = @submission.app
ensure_exists @app
@posts = @app.posts
end
def ensure_exists(value)
unless value.present?
object_name = value.nil? ? "Record" : value.class.name.demodulize
redirect_to admin_timeline_path, alert: "#{object_name} not found."
end
end
end

View File

@@ -5,5 +5,6 @@ class Neighborhood::SyncFromAirtableJob < ApplicationJob
Neighborhood::App.pull_all_from_airtable!
Neighborhood::Project.pull_all_from_airtable!
Neighborhood::Post.pull_all_from_airtable!
Neighborhood::YswsSubmission.pull_all_from_airtable!
end
end

View File

@@ -6,4 +6,14 @@ class Neighborhood::App < ApplicationRecord
has_table_sync base: "appnsN4MzbnfMY0ai",
table: "tbls2fHyQYCtCbYbl",
pat: ENV["NEIGHBORHOOD_AIRTABLE_PAT"]
def posts
return [] unless airtable_fields["devlog"]&.any?
Neighborhood::Post.where(airtable_id: airtable_fields["devlog"])
end
def projects
return [] unless airtable_fields["hackatimeProjects"]&.any?
Neighborhood::Project.where(airtable_id: airtable_fields["hackatimeProjects"])
end
end

View File

@@ -6,4 +6,9 @@ class Neighborhood::Post < ApplicationRecord
has_table_sync base: "appnsN4MzbnfMY0ai",
table: "tbl0iKxglbySiEbB4",
pat: ENV["NEIGHBORHOOD_AIRTABLE_PAT"]
def app
return nil unless airtable_fields["app"]&.first
Neighborhood::App.find_by(airtable_id: airtable_fields["app"].first)
end
end

View File

@@ -0,0 +1,14 @@
class Neighborhood::YswsSubmission < ApplicationRecord
self.table_name = "neighborhood_ysws_submissions"
include HasTableSync
has_table_sync base: "appnsN4MzbnfMY0ai",
table: "tblbyu0FABZJ0wvaJ",
pat: ENV["NEIGHBORHOOD_AIRTABLE_PAT"]
def app
return nil unless airtable_fields["app"]&.first
Neighborhood::App.find_by(airtable_id: airtable_fields["app"].first)
end
end

View File

@@ -0,0 +1,456 @@
<%# app/views/admin/post_reviews/show.html.erb %>
<% content_for :title, "Post Review: #{@post.airtable_fields['description']&.truncate(30) || 'Untitled Post'}" %>
<%
pixels_per_hour = 70
pixels_per_minute = pixels_per_hour / 60.0
timeline_start_hour = 0
timeline_end_hour = 23
day_column_width_px = 320
hour_label_width_rem = 3.5
header_height_px = 40
days_to_display = (@review_start_date..@review_end_date).to_a
num_days_in_review = days_to_display.count
%>
<style>
.post-review-container {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
padding: 1rem; background-color: #1F2937; color: #E5E7EB;
min-height: calc(100vh - 4rem); display: flex; flex-direction: column;
}
.post-review-header h1 { color: #FFF; border-bottom: 1px solid #4A5568; padding-bottom: 0.5rem; margin-bottom: 1rem; font-size: 1.5rem; }
.post-details-section {
margin-bottom: 1.5rem; padding: 1rem; background-color: #2D3748;
border: 1px solid #4A5568; border-radius: 0.5rem; font-size: 0.9rem;
}
.post-details-section h2 { margin-top: 0; color: #CBD5E0; font-size: 1.2rem; }
.post-details-section p { margin-bottom: 0.5rem; color: #A0AEC0; line-height: 1.5; }
.post-details-section a { color: #60A5FA; text-decoration: none; }
.post-details-section a:hover { text-decoration: underline; color: #93C5FD; }
.button-style {
display: inline-block; padding: 0.4rem 0.8rem; background-color: #3B82F6; color: white;
text-decoration: none; border-radius: 0.25rem; font-size: 0.85rem; border: none;
cursor: pointer; transition: background-color 0.2s;
}
.button-style:hover { background-color: #2563EB; }
.timeline-viewport {
overflow-x: auto; flex-grow: 1; border: 1px solid #4A5568;
border-radius: 0.5rem; background-color: #1A202C; display: flex; position: relative;
}
.timeline-grid-container { display: flex; min-height: <%= (timeline_end_hour - timeline_start_hour + 1) * pixels_per_hour + header_height_px %>px; }
.hour-labels-column {
width: <%= hour_label_width_rem %>rem; flex-shrink: 0; position: sticky; left: 0;
background-color: #1A202C; z-index: 30; padding-top: <%= header_height_px %>px;
}
.hour-label-cell {
height: <%= pixels_per_hour %>px; display: flex; align-items: flex-start; justify-content: flex-end;
padding-right: 0.5rem; font-size: 0.7rem; color: #9CA3AF;
border-top: 1px solid #374151; box-sizing: border-box;
}
.hour-label-cell:first-child { border-top: none; }
.days-container { display: flex; position: relative; }
.day-column {
width: <%= day_column_width_px %>px; min-width: <%= day_column_width_px %>px;
border-left: 1px solid #374151; position: relative;
padding-top: <%= header_height_px %>px; box-sizing: border-box;
}
.day-column:first-child { border-left: none; }
.day-header-sticky-wrapper {
position: sticky; top: 0; z-index: 25; display: flex;
background-color: #1A202C; padding-left: <%= hour_label_width_rem %>rem;
}
.day-header {
width: <%= day_column_width_px %>px; height: <%= header_height_px %>px;
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 0.85em; background-color: #2D3748;
border-bottom: 1px solid #4A5568; color: #CBD5E0; box-sizing: border-box;
border-left: 1px solid #374151;
}
.day-header:first-child { border-left: none; }
.day-column-content-wrapper { position: relative; height: 100%; }
.timeline-item {
position: absolute; border-radius: 3px; padding: 2px 5px;
font-size: 0.75em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
box-sizing: border-box; box-shadow: 0 1px 2px rgba(0,0,0,0.4); line-height: 1.4;
display: flex; /* For checkbox alignment */
align-items: center; /* For checkbox alignment */
}
.timeline-item-content { flex-grow: 1; overflow: hidden; text-overflow: ellipsis; }
.span-selector-checkbox { margin-right: 5px; transform: scale(0.8); accent-color: #60A5FA; }
.coding-span {
left: 4px; right: 4px; background-color: #3B82F6; color: white; z-index: 10;
border: 1px solid #2563EB;
}
.coding-span a { color: #BBDEFB; text-decoration: underline; }
.coding-span a:hover { color: #E3F2FD; }
.commit-pill {
position: absolute; right: 5px; background: #4A5568; color: #E2E8F0; z-index: 15;
border: 1px solid #718096; padding: 1px 6px; border-radius: 10px;
font-size: 0.7em; cursor: pointer; width: auto; min-width: 40px; text-align: center;
}
.commit-pill a { color: inherit; text-decoration: none; }
.commit-pill .commit-stats-add { color: #A3E635; font-weight: bold; }
.commit-pill .commit-stats-del { color: #F87171; font-weight: bold; }
.post-activity-line {
position: absolute; background-color: rgba(239, 68, 68, 0.5); z-index: 5;
left: 2px; width: calc(100% - 4px); border-radius: 2px; pointer-events: none;
}
.alert-flash {
background-color: #4B0000; color: #FFCDD2; border: 1px solid #EF9A9A;
padding: 1rem; border-radius: 0.25rem; margin-bottom: 1rem;
}
#cursor-line {
position: absolute; height: 1px; background-color: red; width: 100%;
left: 0; z-index: 35; pointer-events: none; display: none;
}
#cursor-time-display {
position: absolute; background-color: rgba(45, 55, 72, 0.9); color: #FFF;
padding: 2px 5px; font-size: 0.65rem; border-radius: 3px; z-index: 40;
pointer-events: none; display: none; transform: translateY(-50%);
}
#selected-time-summary {
position: fixed;
bottom: 20px;
right: 20px;
background-color: rgba(30, 41, 59, 0.9); /* Tailwind slate-800 with opacity */
color: #E2E8F0; /* Tailwind slate-200 */
padding: 0.75rem 1rem;
border-radius: 0.375rem; /* Tailwind rounded-md */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
z-index: 1000;
font-size: 0.9rem;
min-width: 180px;
text-align: center;
}
#selected-time-summary .seconds {
font-size: 0.75rem;
color: #94A3B8;
margin-top: 0.25rem;
}
</style>
<div class="post-review-container">
<div class="post-review-header">
<h1>
Post Review
<% if @user %>
<span style="font-size: 0.7em; color: #9CA3AF;">for <%= @user.display_name %></span>
<% end %>
</h1>
</div>
<% if flash[:alert] %>
<div class="alert-flash"><%= flash[:alert] %></div>
<% end %>
<section class="post-details-section">
<h2>Post Details</h2>
<% if @user %> <p><strong>User:</strong> <%= render "shared/user_mention", user: @user %></p> <% end %>
<p><strong>Description:</strong> <%= simple_format(@post.airtable_fields["description"]) %></p>
<p>
<strong>Post Period (User TZ: <%= @target_user_timezone %>):</strong>
<%= @post_start_display.strftime("%B %d, %Y %l:%M %p") %>
to
<%= @post_end_display.strftime("%B %d, %Y %l:%M %p") %>
</p>
<p>
<strong>Review Window (Dates in User TZ):</strong>
<%= @review_start_date.strftime("%B %d, %Y") %> to <%= @review_end_date.strftime("%B %d, %Y") %>
</p>
<% if @post.airtable_fields["demoVideo"].present? %>
<%= render "shared/video_on_hover", src: @post.airtable_fields["demoVideo"] %>
<% end %>
<% if @post.airtable_fields["photoboothVideo"].present? %>
<%= render "shared/video_on_hover", src: @post.airtable_fields["photoboothVideo"] %>
<% end %>
<p><strong>Airtable Link:</strong> <%= link_to "View on Airtable", @post.airtable_url, target: "_blank", class: "button-style" %></p>
<p><strong>API Link:</strong> <%= link_to "View on stats endpoint", api_v1_path(username: @user.id, start_date: @post_start_display.iso8601, end_date: @post_end_display.iso8601, features: "projects") %></p>
</section>
<% if @unique_project_names.any? %>
<section class="post-details-section">
<h2>Filter by Project</h2>
<form id="project-filter-form" style="display: flex; flex-direction: column; gap: 1rem;">
<div class="project-filters" style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<% @unique_project_names.each do |project_name| %>
<% is_selected = params[:projects].present? ? params[:projects].split(',').include?(project_name) : true %>
<% is_recommended = @recommended_project_names.include?(project_name) %>
<label class="project-filter-label <%= 'recommended' if is_recommended %>"
style="display: flex; align-items: center; background: <%= is_recommended ? '#1F2937' : '#374151' %>; padding: 0.25rem 0.5rem; border-radius: 0.25rem; cursor: pointer; border: 1px solid <%= is_recommended ? '#60A5FA' : 'transparent' %>;">
<input type="checkbox"
class="project-filter-checkbox"
data-project-name="<%= project_name %>"
<%= is_selected ? 'checked' : '' %>
style="margin-right: 0.5rem; accent-color: #60A5FA;">
<%= project_name %>
<% if is_recommended %>
<span style="margin-left: 0.5rem; font-size: 0.75rem; color: #60A5FA;">(recommended)</span>
<% end %>
</label>
<% end %>
</div>
<button type="submit" class="button-style" style="align-self: flex-start;">Apply Filters</button>
</form>
</section>
<% end %>
<div class="day-header-sticky-wrapper" id="dayHeaderStickyWrapper">
<% days_to_display.each do |day| %>
<div class="day-header">
<%= day.strftime("%a, %b %d") %>
</div>
<% end %>
</div>
<div class="timeline-viewport" id="timelineViewport">
<div class="timeline-grid-container">
<div class="hour-labels-column" id="hourLabelsColumn">
<div id="cursor-time-display">00:00</div>
<% (timeline_start_hour..timeline_end_hour).each do |hour| %>
<div class="hour-label-cell">
<%= Time.utc(2000,1,1, hour).strftime("%-l%P").sub('m','').strip %>
</div>
<% end %>
</div>
<div class="days-container" id="daysContainer">
<div id="cursor-line"></div>
<% days_to_display.each do |day| %>
<div class="day-column">
<div class="day-column-content-wrapper">
<% (timeline_start_hour..timeline_end_hour).each do |hour_idx| %>
<div style="height: <%= pixels_per_hour %>px; border-top: 1px solid #374151; box-sizing: border-box; position: relative;">
<div style="height: 50%; border-bottom: 1px dotted #2D3748; position:absolute; top: 50%; width:100%;"></div>
</div>
<% end %>
<% day_start_in_user_tz = day.in_time_zone(@target_user_timezone).beginning_of_day %>
<% day_end_in_user_tz = day.in_time_zone(@target_user_timezone).end_of_day %>
<%
post_line_start_time_on_day = [@post_start_display, day_start_in_user_tz].max
post_line_end_time_on_day = [@post_end_display, day_end_in_user_tz].min
if post_line_start_time_on_day < post_line_end_time_on_day
post_line_top_offset_minutes = (post_line_start_time_on_day - day_start_in_user_tz) / 60.0
post_line_top_px = post_line_top_offset_minutes * pixels_per_minute
post_line_duration_minutes = (post_line_end_time_on_day - post_line_start_time_on_day) / 60.0
post_line_height_px = [post_line_duration_minutes * pixels_per_minute, 2].max
%>
<div class="post-activity-line"
style="top: <%= post_line_top_px %>px; height: <%= post_line_height_px %>px;"
title="Post Period on this day: <%= post_line_start_time_on_day.strftime('%l:%M%P') %> to <%= post_line_end_time_on_day.strftime('%l:%M%P') %>">
</div>
<% end %>
<%# Coding Spans %>
<% @detailed_spans&.each do |span| %>
<%
span_start_utc = Time.at(span[:start_time])
span_end_utc = Time.at(span[:end_time])
span_start_in_user_tz = span_start_utc.in_time_zone(@target_user_timezone)
span_end_in_user_tz = span_end_utc.in_time_zone(@target_user_timezone)
effective_start_on_day = [span_start_in_user_tz, day_start_in_user_tz].max
effective_end_on_day = [span_end_in_user_tz, day_end_in_user_tz].min
%>
<% if effective_start_on_day < effective_end_on_day %>
<%
top_offset_minutes = (effective_start_on_day - day_start_in_user_tz) / 60.0
duration_minutes_on_day = (effective_end_on_day - effective_start_on_day) / 60.0
span_top_px = top_offset_minutes * pixels_per_minute
span_height_px = [duration_minutes_on_day * pixels_per_minute, 2].max
projects_display = span[:projects_edited_details].map do |p_detail|
name = p_detail[:name].blank? ? "Unknown" : p_detail[:name].truncate(15)
p_detail[:repo_url] ? link_to(name, p_detail[:repo_url], target: "_blank", rel: "noopener noreferrer") : name
end.join(" / ").html_safe
languages_display = span[:languages].join(', ').truncate(20)
duration_display = short_time_simple(span[:duration])
project_names = span[:projects_edited_details].map { |p| p[:name] }.join(',')
tooltip_parts = []
tooltip_parts << "Time: #{span_start_in_user_tz.strftime('%l:%M%P')} - #{span_end_in_user_tz.strftime('%l:%M%P')}"
tooltip_parts << "Actual Duration: #{short_time_detailed(span[:duration])}"
tooltip_parts << "Projects: #{span[:projects_edited_details].map{ |p| p[:name] }.join(', ')}" if span[:projects_edited_details].any?
tooltip_parts << "Languages: #{span[:languages].join(', ')}" if span[:languages].any?
tooltip_parts << "Editors: #{span[:editors].join(', ')}" if span[:editors].any?
tooltip_parts << "Files: #{span[:files_edited].join(', ')}" if span[:files_edited].any?
%>
<div class="timeline-item coding-span"
style="top: <%= span_top_px %>px; height: <%= span_height_px %>px;"
data-projects="<%= project_names %>"
title="<%= tooltip_parts.join("\n") %>">
<input type="checkbox"
class="span-selector-checkbox"
id="<%= span[:id] %>"
data-duration-seconds="<%= span[:duration] %>">
<div class="timeline-item-content">
<%= projects_display %>
</div>
</div>
<% end %>
<% end %>
<%
last_commit_bottom_px = 0
commits_on_this_day = @commits.filter do |commit|
commit.created_at.in_time_zone(@target_user_timezone).to_date == day
end
%>
<% commits_on_this_day.each do |commit| %>
<%
commit_time_in_user_tz = commit.created_at.in_time_zone(@target_user_timezone)
top_offset_minutes = (commit_time_in_user_tz - day_start_in_user_tz) / 60.0
commit_top_px = top_offset_minutes * pixels_per_minute
commit_pill_height = 16
commit_top_px = [commit_top_px - (commit_pill_height / 2), last_commit_bottom_px].max
last_commit_bottom_px = commit_top_px + commit_pill_height + 1
additions = commit.github_raw.dig("stats", "additions") || 0
deletions = commit.github_raw.dig("stats", "deletions") || 0
%>
<a href="<%= commit.github_raw['html_url'] %>" target="_blank" rel="noopener noreferrer"
class="timeline-item commit-pill"
style="top: <%= commit_top_px %>px; height: <%= commit_pill_height %>px;"
title="<%= CGI.escapeHTML(commit.github_raw.dig("commit", "message") || 'No commit message') %>">
<span class="commit-stats-add">+<%= additions %></span>/<span class="commit-stats-del">-<%= deletions %></span>
</a>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
</div>
</div>
<div id="selected-time-summary">
<div>
<span id="selected-duration">0s</span> / <span id="total-post-duration"><%= short_time_detailed(@total_post_hackatime_seconds) %></span>
</div>
<div class="seconds">
<span id="selected-seconds">0</span> / <span id="total-seconds"><%= @total_post_hackatime_seconds %></span> seconds
</div>
</div>
<script>
document.addEventListener('turbo:load', function() {
const timelineViewport = document.getElementById('timelineViewport');
const hourLabelsColumn = document.getElementById('hourLabelsColumn');
const daysContainer = document.getElementById('daysContainer');
const dayHeaderStickyWrapper = document.getElementById('dayHeaderStickyWrapper');
const cursorLine = document.getElementById('cursor-line');
const cursorTimeDisplay = document.getElementById('cursor-time-display');
const selectedDurationEl = document.getElementById('selected-duration');
const totalPostDurationEl = document.getElementById('total-post-duration');
const pixelsPerHour = <%= pixels_per_hour %>;
const pixelsPerMinute = <%= pixels_per_minute %>;
const timelineStartHour = <%= timeline_start_hour %>;
const headerHeight = <%= header_height_px %>;
const totalPostSeconds = <%= @total_post_hackatime_seconds %>;
function formatSecondsToHhMmSs(totalSeconds) {
if (isNaN(totalSeconds) || totalSeconds < 0) return "0s";
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
let parts = [];
if (hours > 0) parts.push(hours + "h");
if (minutes > 0) parts.push(minutes + "m");
if (seconds > 0 || parts.length === 0) parts.push(seconds + "s"); // Show seconds if only unit or total is 0
return parts.join(" ") || "0s";
}
function updateSelectedTimeSummary() {
let currentSelectedSeconds = 0;
document.querySelectorAll('.span-selector-checkbox:checked').forEach(checkbox => {
currentSelectedSeconds += parseInt(checkbox.dataset.durationSeconds, 10) || 0;
});
selectedDurationEl.textContent = formatSecondsToHhMmSs(currentSelectedSeconds);
document.getElementById('selected-seconds').textContent = currentSelectedSeconds;
}
totalPostDurationEl.textContent = formatSecondsToHhMmSs(totalPostSeconds);
document.getElementById('total-seconds').textContent = totalPostSeconds;
updateSelectedTimeSummary(); // Initial call
if (timelineViewport && hourLabelsColumn && daysContainer && cursorLine && cursorTimeDisplay && dayHeaderStickyWrapper) {
timelineViewport.addEventListener('scroll', function() {
dayHeaderStickyWrapper.style.transform = `translateX(-${timelineViewport.scrollLeft}px)`;
});
const postStartDate = new Date('<%= @post_start_display.iso8601 %>');
const reviewStartDate = new Date('<%= @review_start_date.iso8601 %>');
const dayDiff = Math.floor((postStartDate - reviewStartDate) / (1000 * 60 * 60 * 24));
if (dayDiff >= 0 && dayDiff < <%= num_days_in_review %>) {
const scrollAmount = dayDiff * <%= day_column_width_px %>;
timelineViewport.scrollLeft = Math.max(0, scrollAmount - (<%= day_column_width_px %>/2) );
}
daysContainer.addEventListener('mousemove', function(e) {
const rect = daysContainer.getBoundingClientRect();
const viewportRect = timelineViewport.getBoundingClientRect();
let mouseYInGrid = e.clientY - rect.top;
mouseYInGrid = Math.max(0, Math.min(mouseYInGrid, rect.height));
cursorLine.style.top = `${mouseYInGrid}px`;
cursorLine.style.display = 'block';
const minutesFromTimelineStart = mouseYInGrid / pixelsPerMinute;
const totalMinutes = Math.floor(timelineStartHour * 60 + minutesFromTimelineStart);
const displayHours = Math.floor(totalMinutes / 60) % 24;
const displayMinutes = totalMinutes % 60;
cursorTimeDisplay.textContent = `${String(displayHours).padStart(2, '0')}:${String(displayMinutes).padStart(2, '0')}`;
cursorTimeDisplay.style.top = `${mouseYInGrid + headerHeight}px`;
cursorTimeDisplay.style.left = `5px`;
cursorTimeDisplay.style.display = 'block';
});
daysContainer.addEventListener('mouseleave', function() {
cursorLine.style.display = 'none';
cursorTimeDisplay.style.display = 'none';
});
// Listener for checkboxes
daysContainer.addEventListener('change', function(event) {
if (event.target.classList.contains('span-selector-checkbox')) {
updateSelectedTimeSummary();
}
});
}
});
document.getElementById('project-filter-form').addEventListener('submit', function(e) {
e.preventDefault();
const checkboxes = document.querySelectorAll('.project-filter-checkbox');
const selectedProjects = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.dataset.projectName);
const url = new URL(window.location.href);
if (selectedProjects.length === <%= @unique_project_names.length %>) {
url.searchParams.delete('projects');
} else {
url.searchParams.set('projects', selectedProjects.join(','));
}
window.location.href = url.toString();
});
</script>

View File

@@ -0,0 +1,66 @@
<%# app/views/admin/ysws_reviews/show.html.erb %>
<% content_for :title, "YSWS Review: #{@submission.airtable_fields['name']&.truncate(30) || 'Untitled Submission'}" %>
<div class="ysws-review-container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; padding: 1rem; background-color: #1F2937; color: #E5E7EB; min-height: calc(100vh - 4rem);">
<div class="ysws-review-header">
<h1 style="color: #FFF; border-bottom: 1px solid #4A5568; padding-bottom: 0.5rem; margin-bottom: 1rem; font-size: 1.5rem;">
YSWS Submission Review
<% if @user %>
<span style="font-size: 0.7em; color: #9CA3AF;">for <%= @user.display_name %></span>
<% end %>
</h1>
</div>
<% if flash[:alert] %>
<div style="background-color: #4B0000; color: #FFCDD2; border: 1px solid #EF9A9A; padding: 1rem; border-radius: 0.25rem; margin-bottom: 1rem;">
<%= flash[:alert] %>
</div>
<% end %>
<section style="margin-bottom: 1.5rem; padding: 1rem; background-color: #2D3748; border: 1px solid #4A5568; border-radius: 0.5rem; font-size: 0.9rem;">
<h2 style="margin-top: 0; color: #CBD5E0; font-size: 1.2rem;">Submission Details</h2>
<% if @user %>
<p style="margin-bottom: 0.5rem; color: #A0AEC0; line-height: 1.5;">
<strong>User:</strong> <%= render "shared/user_mention", user: @user %>
</p>
<% end %>
<% if @app.airtable_fields["Icon"]&.any? %>
<img src="<%= @app.airtable_fields["Icon"]&.first %>" alt="App Logo" style="width: 100px; height: 100px; border-radius: 50%; margin-bottom: 1rem;">
<% end %>
<p style="margin-bottom: 0.5rem; color: #A0AEC0; line-height: 1.5;">
<strong>App:</strong> <%= @app.airtable_fields["Name"] %>
</p>
<p style="margin-bottom: 0.5rem; color: #A0AEC0; line-height: 1.5;">
<strong>Airtable Link:</strong>
<%= link_to "View on Airtable", @submission.airtable_url, target: "_blank", style: "color: #60A5FA; text-decoration: none;" %>
</p>
</section>
<section style="margin-bottom: 1.5rem; padding: 1rem; background-color: #2D3748; border: 1px solid #4A5568; border-radius: 0.5rem; font-size: 0.9rem;">
<h2 style="margin-top: 0; color: #CBD5E0; font-size: 1.2rem;">Related Posts</h2>
<% if @posts.any? %>
<ul style="list-style: none; padding: 0; margin: 0;">
<% @posts.each do |post| %>
<li style="margin-bottom: 1rem; padding: 1rem; background-color: #1F2937; border: 1px solid #4A5568; border-radius: 0.375rem;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<h3 style="margin: 0; color: #E5E7EB; font-size: 1rem;">
<%= post.airtable_fields["description"]&.truncate(100) || "Untitled Post" %>
</h3>
<span style="background-color: #374151; color: #E5E7EB; padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem;">
<%= post.airtable_fields["review_status"] || "No Status" %>
</span>
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<%= link_to "View Post Review", admin_post_review_path(post.airtable_id),
style: "display: inline-block; padding: 0.4rem 0.8rem; background-color: #3B82F6; color: white; text-decoration: none; border-radius: 0.25rem; font-size: 0.85rem;" %>
<%= link_to "View on Airtable", post.airtable_url, target: "_blank",
style: "display: inline-block; padding: 0.4rem 0.8rem; background-color: #374151; color: #E5E7EB; text-decoration: none; border-radius: 0.25rem; font-size: 0.85rem;" %>
</div>
</li>
<% end %>
</ul>
<% else %>
<p style="color: #A0AEC0;">No posts found for this submission.</p>
<% end %>
</section>
</div>

View File

@@ -0,0 +1,20 @@
<%# This is a partial for a video that plays on hover. it's provided a src %>
<video
class="video-on-hover"
src="<%= src %>"
controls
playsinline
onmouseover="this.play()"
<%# onmouseout="this.pause()" %>
>
Your browser does not support the video tag.
</video>
<style>
.video-on-hover {
border-radius: 4px;
border: 1px solid #ccc;
max-width: 200px;
max-height: 200px;
}
</style>

View File

@@ -4,8 +4,15 @@ Rails.application.configure do
config.good_job.cleanup_interval_jobs = 1000
config.good_job.cleanup_interval_seconds = 3600
config.good_job.enable_cron = true
config.good_job.execution_mode = :async
if Rails.env.development?
config.good_job.enable_listening = false
config.good_job.poll_interval = -1 # Disable polling
config.good_job.execution_mode = :inline # Run jobs inline in development
else
config.good_job.execution_mode = :async
end
config.good_job.enable_cron = Rails.env.production?
# https://github.com/bensheldon/good_job#configuring-your-queues
config.good_job.queues = "latency_10s:8; latency_5m,latency_10s:6; literally_whenever,*,latency_5m,latency_10s:10"

View File

@@ -20,6 +20,11 @@ Rails.application.routes.draw do
get "timeline", to: "timeline#show", as: :timeline
get "timeline/search_users", to: "timeline#search_users"
get "timeline/leaderboard_users", to: "timeline#leaderboard_users"
get "post_reviews/:post_id", to: "post_reviews#show", as: :post_review
get "post_reviews/:post_id/date/:date", to: "post_reviews#show", as: :post_review_on_date
get "ysws_reviews/:record_id", to: "ysws_reviews#show", as: :ysws_review
end
if Rails.env.development?

View File

@@ -0,0 +1,11 @@
class CreateNeighborhoodYswsSubmissions < ActiveRecord::Migration[8.0]
def change
create_table :neighborhood_ysws_submissions do |t|
t.string :airtable_id, null: false
t.index :airtable_id, unique: true
t.jsonb :airtable_fields
t.timestamps
end
end
end

10
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.0].define(version: 2025_05_16_142043) do
ActiveRecord::Schema[8.0].define(version: 2025_05_22_062125) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -285,6 +285,14 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_16_142043) do
t.index ["airtable_id"], name: "index_neighborhood_projects_on_airtable_id", unique: true
end
create_table "neighborhood_ysws_submissions", force: :cascade do |t|
t.string "airtable_id", null: false
t.jsonb "airtable_fields"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["airtable_id"], name: "index_neighborhood_ysws_submissions_on_airtable_id", unique: true
end
create_table "physical_mails", force: :cascade do |t|
t.bigint "user_id", null: false
t.integer "mission_type", null: false