mirror of
https://github.com/SrIzan10/hc-harbor.git
synced 2026-05-01 10:45:21 +00:00
Initial implementation of post_reviews_controller (#247)
This commit is contained in:
139
app/controllers/admin/post_reviews_controller.rb
Normal file
139
app/controllers/admin/post_reviews_controller.rb
Normal 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
|
||||
25
app/controllers/admin/ysws_reviews_controller.rb
Normal file
25
app/controllers/admin/ysws_reviews_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
14
app/models/neighborhood/ysws_submission.rb
Normal file
14
app/models/neighborhood/ysws_submission.rb
Normal 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
|
||||
456
app/views/admin/post_reviews/show.html.erb
Normal file
456
app/views/admin/post_reviews/show.html.erb
Normal 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>
|
||||
66
app/views/admin/ysws_reviews/show.html.erb
Normal file
66
app/views/admin/ysws_reviews/show.html.erb
Normal 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>
|
||||
20
app/views/shared/_video_on_hover.html.erb
Normal file
20
app/views/shared/_video_on_hover.html.erb
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
10
db/schema.rb
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user