diff --git a/app/controllers/admin/post_reviews_controller.rb b/app/controllers/admin/post_reviews_controller.rb new file mode 100644 index 0000000..5f7e820 --- /dev/null +++ b/app/controllers/admin/post_reviews_controller.rb @@ -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 diff --git a/app/controllers/admin/ysws_reviews_controller.rb b/app/controllers/admin/ysws_reviews_controller.rb new file mode 100644 index 0000000..b59fab4 --- /dev/null +++ b/app/controllers/admin/ysws_reviews_controller.rb @@ -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 diff --git a/app/jobs/neighborhood/sync_from_airtable_job.rb b/app/jobs/neighborhood/sync_from_airtable_job.rb index a4bcec4..cd1e89c 100644 --- a/app/jobs/neighborhood/sync_from_airtable_job.rb +++ b/app/jobs/neighborhood/sync_from_airtable_job.rb @@ -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 diff --git a/app/models/neighborhood/app.rb b/app/models/neighborhood/app.rb index a4712eb..9e2ce62 100644 --- a/app/models/neighborhood/app.rb +++ b/app/models/neighborhood/app.rb @@ -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 diff --git a/app/models/neighborhood/post.rb b/app/models/neighborhood/post.rb index 883a95c..718eae1 100644 --- a/app/models/neighborhood/post.rb +++ b/app/models/neighborhood/post.rb @@ -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 diff --git a/app/models/neighborhood/ysws_submission.rb b/app/models/neighborhood/ysws_submission.rb new file mode 100644 index 0000000..ca67da2 --- /dev/null +++ b/app/models/neighborhood/ysws_submission.rb @@ -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 diff --git a/app/views/admin/post_reviews/show.html.erb b/app/views/admin/post_reviews/show.html.erb new file mode 100644 index 0000000..620877f --- /dev/null +++ b/app/views/admin/post_reviews/show.html.erb @@ -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 +%> + + + +
+
+

+ Post Review + <% if @user %> + for <%= @user.display_name %> + <% end %> +

+
+ + <% if flash[:alert] %> +
<%= flash[:alert] %>
+ <% end %> + +
+

Post Details

+ <% if @user %>

User: <%= render "shared/user_mention", user: @user %>

<% end %> +

Description: <%= simple_format(@post.airtable_fields["description"]) %>

+

+ Post Period (User TZ: <%= @target_user_timezone %>): + <%= @post_start_display.strftime("%B %d, %Y %l:%M %p") %> + to + <%= @post_end_display.strftime("%B %d, %Y %l:%M %p") %> +

+

+ Review Window (Dates in User TZ): + <%= @review_start_date.strftime("%B %d, %Y") %> to <%= @review_end_date.strftime("%B %d, %Y") %> +

+ <% 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 %> +

Airtable Link: <%= link_to "View on Airtable", @post.airtable_url, target: "_blank", class: "button-style" %>

+

API Link: <%= 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") %>

+
+ + <% if @unique_project_names.any? %> +
+

Filter by Project

+
+
+ <% @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) %> + + <% end %> +
+ +
+
+ <% end %> + +
+ <% days_to_display.each do |day| %> +
+ <%= day.strftime("%a, %b %d") %> +
+ <% end %> +
+ +
+
+
+
00:00
+ <% (timeline_start_hour..timeline_end_hour).each do |hour| %> +
+ <%= Time.utc(2000,1,1, hour).strftime("%-l%P").sub('m','').strip %> +
+ <% end %> +
+ +
+
+ <% days_to_display.each do |day| %> +
+
+ <% (timeline_start_hour..timeline_end_hour).each do |hour_idx| %> +
+
+
+ <% 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 + %> +
+
+ <% 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? + %> +
"> + +
+ <%= projects_display %> +
+
+ <% 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 + %> + "> + +<%= additions %>/-<%= deletions %> + + <% end %> +
+
+ <% end %> +
+
+
+
+ +
+
+ 0s / <%= short_time_detailed(@total_post_hackatime_seconds) %> +
+
+ 0 / <%= @total_post_hackatime_seconds %> seconds +
+
+ + \ No newline at end of file diff --git a/app/views/admin/ysws_reviews/show.html.erb b/app/views/admin/ysws_reviews/show.html.erb new file mode 100644 index 0000000..faa1733 --- /dev/null +++ b/app/views/admin/ysws_reviews/show.html.erb @@ -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'}" %> + +
+
+

+ YSWS Submission Review + <% if @user %> + for <%= @user.display_name %> + <% end %> +

+
+ + <% if flash[:alert] %> +
+ <%= flash[:alert] %> +
+ <% end %> + +
+

Submission Details

+ <% if @user %> +

+ User: <%= render "shared/user_mention", user: @user %> +

+ <% end %> + <% if @app.airtable_fields["Icon"]&.any? %> + " alt="App Logo" style="width: 100px; height: 100px; border-radius: 50%; margin-bottom: 1rem;"> + <% end %> +

+ App: <%= @app.airtable_fields["Name"] %> +

+

+ Airtable Link: + <%= link_to "View on Airtable", @submission.airtable_url, target: "_blank", style: "color: #60A5FA; text-decoration: none;" %> +

+
+ +
+

Related Posts

+ <% if @posts.any? %> + + <% else %> +

No posts found for this submission.

+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/shared/_video_on_hover.html.erb b/app/views/shared/_video_on_hover.html.erb new file mode 100644 index 0000000..e8fdd45 --- /dev/null +++ b/app/views/shared/_video_on_hover.html.erb @@ -0,0 +1,20 @@ +<%# This is a partial for a video that plays on hover. it's provided a src %> + + + \ No newline at end of file diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index c974042..120bf22 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -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" diff --git a/config/routes.rb b/config/routes.rb index 99f1cdc..612b5e3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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? diff --git a/db/migrate/20250522062125_create_neighborhood_ysws_submissions.rb b/db/migrate/20250522062125_create_neighborhood_ysws_submissions.rb new file mode 100644 index 0000000..a1f3ba1 --- /dev/null +++ b/db/migrate/20250522062125_create_neighborhood_ysws_submissions.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 36bc0c4..2bb1291 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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