Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e163563
Bump lodash in the npm_and_yarn group across 1 directory
dependabot[bot] Feb 9, 2026
86fdd37
Enhance EntryDragController with loading overlay improvements
rsmoke Feb 10, 2026
4d4df9d
Add notify_completed action to JudgingRoundsController and related ma…
rsmoke Feb 11, 2026
203e879
Refactor ranking count check in JudgingRoundsController
rsmoke Feb 11, 2026
0dc4f29
Enhance EntryDragController with loading overlay functionality improv…
rsmoke Feb 11, 2026
b863d05
Optimize JudgeDashboard for performance and clarity
rsmoke Feb 11, 2026
fb877b5
Refactor ContestInstancePolicy to enhance ranking checks
rsmoke Feb 11, 2026
c178625
Merge pull request #181 from lsa-mis/tweak_slow_connection_notice
rsmoke Feb 11, 2026
5b51f7d
Merge pull request #179 from lsa-mis/dependabot/npm_and_yarn/npm_and_…
rsmoke Feb 11, 2026
f560be4
Enhance ranking update logic in JudgingRoundsController
rsmoke Feb 11, 2026
329a023
Add tests for update_rankings action in JudgingRoundsController
rsmoke Feb 12, 2026
c2ddd8a
Remove authorization check for notify_completed action in JudgingRoun…
rsmoke Feb 16, 2026
b2c2cf1
Add error handling for missing contact email in JudgeCompletedEvaluat…
rsmoke Feb 16, 2026
89865c5
Refactor JudgeDashboardController to optimize entry ranking queries
rsmoke Feb 16, 2026
012c88c
Refactor entry ranking logic in JudgingRoundsController for improved …
rsmoke Feb 16, 2026
e6c3cd6
Enhance tests for EntryDragController by utilizing jest fake timers
rsmoke Feb 16, 2026
ef9b81d
Refactor entry loading overlay handling in EntryDragController
rsmoke Feb 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion app/controllers/judge_dashboard_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,26 @@ def index
)
.where(contest_instance_id: @judging_assignments.pluck(:contest_instance_id))

assigned_round_ids = @assigned_rounds.pluck(:id)

@entry_rankings = EntryRanking.includes(:judging_round)
.where(user: current_user)
.joins(judging_round: :contest_instance)
.where(judging_rounds: { id: @assigned_rounds.pluck(:id) })
.where(judging_rounds: { id: assigned_round_ids })

# Precompute ranked entry counts per judging_round for this user to avoid N+1 queries
@ranked_counts_by_round = EntryRanking.where(
user: current_user,
judging_round_id: assigned_round_ids
).group(:judging_round_id).count

# Precompute finalized status per judging_round to avoid N+1 queries
# Check if any EntryRankings are finalized for each round
finalized_round_ids = EntryRanking.where(
user: current_user,
judging_round_id: assigned_round_ids,
finalized: true
).select(:judging_round_id).distinct.pluck(:judging_round_id)
@finalized_by_round = finalized_round_ids.index_with { true }
end
end
67 changes: 64 additions & 3 deletions app/controllers/judging_rounds_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class JudgingRoundsController < ApplicationController
before_action :set_contest_instance
before_action :set_judging_round, only: [ :show, :edit, :update, :destroy, :activate, :deactivate, :complete, :uncomplete, :update_rankings, :finalize_rankings, :send_instructions ]
before_action :set_judging_round, only: [ :show, :edit, :update, :destroy, :activate, :deactivate, :complete, :uncomplete, :update_rankings, :finalize_rankings, :send_instructions, :notify_completed ]
before_action :authorize_contest_instance
before_action :check_edit_warning, only: [ :edit, :update ]

Expand Down Expand Up @@ -196,11 +196,11 @@ def update_rankings
entry_ranking.save(validate: false)
end
else
# This is a single entry update (comment update)
# This is a single entry update (comment update or unrank)
ranking_data = rankings.first
entry_id = ranking_data['entry_id'].presence || ranking_data[:entry_id].presence

if entry_id && ranking_data['rank'].present?
if entry_id && (ranking_data['rank'].present? || ranking_data[:rank].present?)
entry = Entry.find(entry_id)
entry_ranking = EntryRanking.find_or_initialize_by(
entry: entry,
Expand All @@ -212,6 +212,17 @@ def update_rankings
entry_ranking.internal_comments = ranking_data['internal_comments'].presence || ranking_data[:internal_comments].presence || entry_ranking.internal_comments
entry_ranking.external_comments = ranking_data['external_comments'].presence || ranking_data[:external_comments].presence || entry_ranking.external_comments
entry_ranking.save(validate: false)
elsif entry_id && (ranking_data.key?('rank') || ranking_data.key?(:rank)) && ranking_data['rank'].blank? && ranking_data[:rank].blank?
# Unranking: only when rank was explicitly sent and is blank (not when key omitted)
current_rankings.where(entry_id: entry_id).destroy_all
elsif entry_id && !(ranking_data.key?('rank') || ranking_data.key?(:rank))
# Comment-only update: rank key omitted; update comments on existing ranking only
entry_ranking = current_rankings.find_by(entry_id: entry_id)
if entry_ranking
entry_ranking.internal_comments = ranking_data['internal_comments'].presence || ranking_data[:internal_comments].presence || entry_ranking.internal_comments
entry_ranking.external_comments = ranking_data['external_comments'].presence || ranking_data[:external_comments].presence || entry_ranking.external_comments
entry_ranking.save(validate: false)
end
end
end

Expand Down Expand Up @@ -327,6 +338,56 @@ def finalize_rankings
end
end

def notify_completed
entry_rankings = EntryRanking.where(
judging_round: @judging_round,
user: current_user
)

ranked_count = entry_rankings.count

if ranked_count < @judging_round.required_entries_count
message = "Please rank at least #{@judging_round.required_entries_count} entries before notifying. You have #{ranked_count} ranked."
respond_to do |format|
format.html { redirect_to judge_dashboard_path, alert: message }
format.turbo_stream do
render turbo_stream: turbo_stream.update('flash',
partial: 'shared/flash',
locals: { message: message, type: 'danger' }
)
end
end
return
end

if @container.contact_email.blank?
message = 'No contact email is set for this contest. Please contact an administrator.'
respond_to do |format|
format.html { redirect_to judge_dashboard_path, alert: message }
format.turbo_stream do
render turbo_stream: turbo_stream.update('flash',
partial: 'shared/flash',
locals: { message: message, type: 'danger' }
)
end
end
return
end

JudgeCompletedEvaluationsMailer.notify_contact(current_user, @judging_round).deliver_later

message = 'The contest contact has been notified that you have completed your evaluations.'
respond_to do |format|
format.html { redirect_to judge_dashboard_path, notice: message }
format.turbo_stream do
render turbo_stream: turbo_stream.update('flash',
partial: 'shared/flash',
locals: { message: message, type: 'success' }
)
end
end
end

private

def set_contest_instance
Expand Down
65 changes: 47 additions & 18 deletions app/javascript/controllers/entry_drag_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export default class extends Controller {
font-weight: 500;
}

.card.entry-card-loading {
overflow: visible;
}

.card.entry-card-loading .entry-loading-overlay {
z-index: 9999;
}

@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
Expand All @@ -88,6 +96,7 @@ export default class extends Controller {

this.contestGroupName = `entries-${accordionSection.id}`
this.accordionSection = accordionSection
this.slowConnectionTimeouts = new WeakMap()

this.initializeSortable()
this.initializeCommentListeners()
Expand Down Expand Up @@ -423,27 +432,37 @@ export default class extends Controller {
async addToRanked(event) {
if (this.finalizedValue) return

const entryCard = event.target.closest('[data-entry-id]')
const entryCard = event.target.closest('.card[data-entry-id]')
if (!entryCard) return

// Move the card to rated entries
// Show loading overlay just like drag-and-drop (before moving so overlay moves with card)
this.addLoadingOverlay(entryCard)

// Move the card to rated entries (card may be inside a turbo-frame; we move the card node)
this.ratedEntriesTarget.appendChild(entryCard)
entryCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' })

await this.handleSortEnd({ from: this.availableEntriesTarget, to: this.ratedEntriesTarget, item: entryCard })
}

async removeFromRanked(event) {
if (this.finalizedValue) return

const entryCard = event.target.closest('[data-entry-id]')
const entryCard = event.target.closest('.card[data-entry-id]')
if (!entryCard) return

// Show confirmation dialog
if (!confirm("Are you sure you want to unrank this entry? Any comments you have written will be deleted.")) {
return
}

// Show loading overlay just like drag-and-drop (before moving so overlay moves with card)
this.addLoadingOverlay(entryCard)

// Move the card back to available entries
this.availableEntriesTarget.appendChild(entryCard)
entryCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' })

await this.handleSortEnd({ from: this.ratedEntriesTarget, to: this.availableEntriesTarget, item: entryCard })
}

Expand All @@ -465,17 +484,16 @@ export default class extends Controller {
</div>
`

// Ensure the card has position relative for absolute positioning of overlay
// Ensure the card has position relative and won't clip the overlay
element.style.position = 'relative'
element.classList.add('entry-card-loading')
element.appendChild(overlay)

// Add show class after a small delay to trigger fade-in
requestAnimationFrame(() => {
overlay.classList.add('show')
})
// Show overlay immediately so it's visible for button clicks (card is about to move)
overlay.classList.add('show')

// Set up slow connection warning
this.slowConnectionTimeout = setTimeout(() => {
// Set up slow connection warning (per-element so multiple overlays clean up correctly)
const timeoutId = setTimeout(() => {
const statusText = overlay.querySelector('.status-text')
if (statusText) {
statusText.innerHTML = `
Expand All @@ -490,6 +508,7 @@ export default class extends Controller {
`
}
}, 5000) // Show warning after 5 seconds
this.slowConnectionTimeouts.set(element, timeoutId)
}

// Helper method to show success state on the overlay before removing it
Expand All @@ -499,9 +518,10 @@ export default class extends Controller {
const overlay = element.querySelector('.entry-loading-overlay')
if (!overlay) return

if (this.slowConnectionTimeout) {
clearTimeout(this.slowConnectionTimeout)
this.slowConnectionTimeout = null
const timeoutId = this.slowConnectionTimeouts.get(element)
if (timeoutId) {
clearTimeout(timeoutId)
this.slowConnectionTimeouts.delete(element)
}

overlay.classList.add('success')
Expand All @@ -521,9 +541,18 @@ export default class extends Controller {
const overlay = element.querySelector('.entry-loading-overlay')
if (!overlay) return

// Clear slow connection timeout
if (this.slowConnectionTimeout) {
clearTimeout(this.slowConnectionTimeout)
// Clear slow connection timeout for this element
const timeoutId = this.slowConnectionTimeouts.get(element)
if (timeoutId) {
clearTimeout(timeoutId)
this.slowConnectionTimeouts.delete(element)
}

// Remove entry-card-loading only when the overlay is removed, so the overlay
// keeps overflow/z-index styling until it's gone (avoids clipping during transition/error).
const removeOverlayAndClass = () => {
element.classList.remove('entry-card-loading')
overlay.remove()
}

if (error) {
Expand All @@ -542,12 +571,12 @@ export default class extends Controller {
// Remove error overlay after 2 seconds
setTimeout(() => {
overlay.classList.remove('show')
setTimeout(() => overlay.remove(), 300)
setTimeout(removeOverlayAndClass, 300)
}, 2000)
} else {
overlay.classList.remove('show')
// Remove overlay after transition completes
setTimeout(() => overlay.remove(), 300)
setTimeout(removeOverlayAndClass, 300)
}
}
}
25 changes: 25 additions & 0 deletions app/mailers/judge_completed_evaluations_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

class JudgeCompletedEvaluationsMailer < ApplicationMailer
def notify_contact(judge, judging_round)
@judge = judge
@judging_round = judging_round
@contest_instance = judging_round.contest_instance
@contest_description = @contest_instance.contest_description
@container = @contest_description.container
@judge_email = judge.normalize_email

to_email = @container.contact_email.presence
unless to_email
raise ArgumentError, "contact_email is required for JudgeCompletedEvaluationsMailer.notify_contact"
end

subject = "Judge completed evaluations: #{@contest_description.name} - Round #{@judging_round.round_number}"

mail(
to: to_email,
subject: subject,
reply_to: @judge_email
)
end
end
10 changes: 8 additions & 2 deletions app/policies/contest_instance_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ def manage?

def update_rankings?
return false unless user && record
return false unless record.judging_open?
return false unless record.judging_open?(user)
record.judges.include?(user)
end

def finalize_rankings?
return false unless user && record
return false unless record.judging_open?
return false unless record.judging_open?(user)
record.judges.include?(user)
end

Expand Down Expand Up @@ -92,4 +92,10 @@ def export_entries?
def send_instructions?
user&.has_container_role?(record.contest_description.container) || axis_mundi?
end

def notify_completed?
return false unless user && record
return false unless record.judging_open?(user)
record.judges.include?(user)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
<h1 style="color: #003366; font-size: 24px; margin-bottom: 20px;">
Judge completed evaluations
</h1>

<p style="font-size: 16px; line-height: 1.6; color: #333;">
A judge has completed their evaluations for the following contest and round.
</p>

<div style="background-color: #f0f7ff; padding: 15px; border-radius: 5px; margin: 20px 0;">
<h2 style="color: #003366; font-size: 18px; margin-bottom: 10px;">Judge</h2>
<p style="font-size: 16px; line-height: 1.6; color: #333; margin: 5px 0;">
<strong>Name:</strong> <%= @judge.display_name_or_first_name_last_name %><br>
<strong>Email:</strong> <%= mail_to @judge_email, @judge_email %>
</p>
</div>

<div style="background-color: #f9f9f9; padding: 15px; border-radius: 5px; margin: 20px 0; border: 1px solid #ddd;">
<h2 style="color: #003366; font-size: 18px; margin-bottom: 10px;">Contest and round</h2>
<p style="font-size: 16px; line-height: 1.6; color: #333; margin: 5px 0;">
<strong>Contest:</strong> <%= @contest_description.name %><br>
<strong>Contest instance:</strong> <%= @contest_instance.id %> (date open: <%= @contest_instance.date_open&.strftime('%Y-%m-%d') %>)<br>
<strong>Round:</strong> <%= @judging_round.round_number %>
</p>
</div>

<p style="font-size: 14px; line-height: 1.6; color: #666;">
You can reply directly to this message to contact the judge.
</p>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
A judge has completed their evaluations for the following contest and round.

Judge: <%= @judge.display_name_or_first_name_last_name %>
Judge email: <%= @judge_email %>

Contest: <%= @contest_description.name %>
Contest instance: <%= @contest_instance.id %> (date open: <%= @contest_instance.date_open&.strftime('%Y-%m-%d') %>)
Round: <%= @judging_round.round_number %>

You can reply directly to this message to contact the judge.
Loading