diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb index 508bfaf2ca140751bbfe60897874eddf13d95f08..54405433becf551f87b53318de65d5411168d81e 100644 --- a/app/controllers/assignments_controller.rb +++ b/app/controllers/assignments_controller.rb @@ -35,17 +35,31 @@ class AssignmentsController < ApplicationController @user = User.find(params[:user_id]) @assignment = Assignment.new(user: @user, session: @session) - if @assignment.save - Rails.logger.debug("Saved assignment #{@assignment.inspect}") - Turbo::StreamsChannel.broadcast_replace_to( - @session.conference, - target: helpers.dom_id(@session), - partial: "sessions/session", - locals: { session: @session } - ) + if @assignment.save # Model callbacks will handle candidate removal and broadcast + Rails.logger.debug("Saved assignment #{@assignment.inspect}. Model callbacks will handle candidate removal and broadcast.") + + # Ensure @session is fresh for the current user's direct response, + # reflecting changes made by model callbacks (candidate removal). + # The model's broadcast already uses session.reload. + # For the direct response, if @session was already loaded, reloading it here + # ensures it's consistent with what the broadcast will show. + @session.reload + flash.now[:success] = "User assigned successfully." + + # Calculate new workload for the affected user + new_workload_hours = (@user.workload_minutes / 60.0).round(1) + dispatch_event_script = "document.dispatchEvent(new CustomEvent('conference-workload:user-updated', { detail: { userId: #{@user.id}, newHours: #{new_workload_hours} } }));" + respond_to do |format| - format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.dom_id(@session), partial: "sessions/session", locals: { session: @session }) } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace(helpers.dom_id(@session), partial: "sessions/session", locals: { session: @session }), + turbo_stream.append_all("body", "<script type='text/javascript'>#{dispatch_event_script}</script>".html_safe) + # Consider a more robust way if append_all to body with script tag is problematic, + # e.g. a dedicated turbo_frame for scripts or a custom turbo_stream action. + ] + end format.html { redirect_to conference_session_path(@session.conference, @session), success: "User assigned successfully." } end else @@ -61,16 +75,27 @@ class AssignmentsController < ApplicationController @assignment = Assignment.find(params[:id]) @session = @assignment.session - if @assignment&.destroy - Rails.logger.debug("destroyed assignment") - Turbo::StreamsChannel.broadcast_replace_later_to( - @session.conference, - target: helpers.dom_id(@session), - partial: "sessions/session", - locals: { session: @session } - ) + # user_for_workload_update must be captured before @assignment is destroyed + user_for_workload_update = @assignment.user + + if @assignment.destroy # Model callbacks will handle candidate recreation and broadcast + Rails.logger.debug("Destroyed assignment. Model callbacks will handle candidate recreation and broadcast.") + + # Ensure @session is fresh for the current user's direct response, + # reflecting changes made by model callbacks (candidate recreation). + @session.reload + + # Calculate new workload for the affected user + new_workload_hours = (user_for_workload_update.workload_minutes / 60.0).round(1) + dispatch_event_script = "document.dispatchEvent(new CustomEvent('conference-workload:user-updated', { detail: { userId: #{user_for_workload_update.id}, newHours: #{new_workload_hours} } }));" + respond_to do |format| - format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.dom_id(@session), partial: "sessions/session", locals: { session: @session }) } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace(helpers.dom_id(@session), partial: "sessions/session", locals: { session: @session }), # Uses reloaded @session + turbo_stream.append_all("body", "<script type='text/javascript'>#{dispatch_event_script}</script>".html_safe) + ] + end format.html { redirect_to conference_session_path(@session.conference, @session), notice: "User removed successfully." } end else diff --git a/app/controllers/candidates_controller.rb b/app/controllers/candidates_controller.rb index d59906784beed8224e8639d315969ea0047b8216..a754e3de0f7b64f401de5f0a6f036f0d1ba05a63 100644 --- a/app/controllers/candidates_controller.rb +++ b/app/controllers/candidates_controller.rb @@ -20,10 +20,23 @@ class CandidatesController < ApplicationController partial: "sessions/session", locals: { session: @session } ) - flash.now[:success] = "User assigned successfully." + flash.now[:success] = "Successfully registered as candidate." # Message updated for clarity + + # Dispatch event for Stimulus controller to update this user's workload display + # Workload itself doesn't change by becoming a candidate, but we need to ensure + # the hours are displayed on the newly added/updated avatar. + user_for_workload_update = @candidate.user + current_workload_hours = (user_for_workload_update.workload_minutes / 60.0).round(1) + dispatch_event_script = "document.dispatchEvent(new CustomEvent('conference-workload:user-updated', { detail: { userId: #{user_for_workload_update.id}, newHours: #{current_workload_hours} } }));" + respond_to do |format| - format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.dom_id(@session), partial: "sessions/session", locals: { session: @session }) } - format.html { redirect_to conference_session_path(@session.conference, @session), success: "User assigned successfully." } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace(helpers.dom_id(@session), partial: "sessions/session", locals: { session: @session }), + turbo_stream.append_all("body", "<script type='text/javascript'>#{dispatch_event_script}</script>".html_safe) + ] + end + format.html { redirect_to conference_session_path(@session.conference, @session), success: "Successfully registered as candidate." } end else flash.now[:alert] = "Failed to record candidate." diff --git a/app/controllers/conferences_controller.rb b/app/controllers/conferences_controller.rb index 704dda1c1195b175e0c281da137e98cd84a4614d..00802ec6933b088a5f973dbe2e3eb966e43f413a 100644 --- a/app/controllers/conferences_controller.rb +++ b/app/controllers/conferences_controller.rb @@ -60,8 +60,7 @@ class ConferencesController < ApplicationController def show @conference = Conference.find_by(slug: params[:slug]) - @sessions = @conference.sessions.scheduled.where.not(starts_at: nil).includes(:stage, - :assignments).where(stage: @conference.relevant_stages).order(:starts_at) + @sessions = @conference.sessions.scheduled.where.not(starts_at: nil).includes(:stage, :assignments, :candidates).where(stage: @conference.relevant_stages).order(:starts_at) @standby_blocks = @conference.standby_blocks.active.includes(:standby_assignments, :users).order(:starts_at) if params[:date] @@ -87,6 +86,20 @@ class ConferencesController < ApplicationController # Combine sessions and standby blocks for timeline display @all_timeline_items = @sessions.to_a + @standby_blocks.to_a + # Get all user IDs from assignments and candidates for the current conference's sessions + # Note: @sessions is already filtered by relevant stages and potentially date + assigned_user_ids = @sessions.flat_map(&:assignments).map(&:user_id) + candidate_user_ids = @sessions.flat_map(&:candidates).map(&:user_id) + all_relevant_user_ids = (assigned_user_ids + candidate_user_ids).uniq + + # Fetch these users and precompute their workloads + # User#workload_minutes already filters by active conferences + @user_workloads = User.where(id: all_relevant_user_ids).each_with_object({}) do |user, hash| + hash[user.id] = (user.workload_minutes / 60.0).round(1) + end + + # @users is used by the filteredlist partial, ensure it contains all users for the dropdown. + # If the dropdown should also be limited to relevant users, this can be changed. @users = User.all end diff --git a/app/javascript/controllers/conference_workload_controller.js b/app/javascript/controllers/conference_workload_controller.js new file mode 100644 index 0000000000000000000000000000000000000000..fa364a0ee6215602972f72f919a8bc937fccd046 --- /dev/null +++ b/app/javascript/controllers/conference_workload_controller.js @@ -0,0 +1,86 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { userWorkloads: Object } + + connect() { + this.updateAllAvatarWorkloads() + this.boundHandleUserWorkloadUpdate = this.handleUserWorkloadUpdate.bind(this) + document.addEventListener("conference-workload:user-updated", this.boundHandleUserWorkloadUpdate) + } + + disconnect() { + document.removeEventListener("conference-workload:user-updated", this.boundHandleUserWorkloadUpdate) + } + + updateAllAvatarWorkloads() { + const workloads = this.userWorkloadsValue || {} + const avatarContainers = this.element.querySelectorAll(".user-avatar-container[data-user-id]") + + avatarContainers.forEach(container => { + const userId = container.dataset.userId + const workloadHours = workloads[userId] + + const hoursDisplaySpan = container.querySelector(".user-workload-hours-display") + const userName = container.querySelector("span[style^='color:']").textContent.split(" ")[0] // Get current name, assuming it's first part + + if (hoursDisplaySpan) { + if (workloadHours !== undefined && workloadHours !== null) { + hoursDisplaySpan.textContent = `(${workloadHours}h)` + container.title = `${userName} (${workloadHours}h)` + } else { + hoursDisplaySpan.textContent = "" // Clear if no workload data + container.title = userName // Reset title to just name + } + } + }) + } + + // This method can be called if the userWorkloadsValue is updated dynamically + // For example, after a Turbo Stream update that changes the data attribute on the controller's element. + userWorkloadsValueChanged() { + // This is called when the data-conference-workload-user-workloads-value changes + this.updateAllAvatarWorkloads() + } + + handleUserWorkloadUpdate(event) { + const { userId, newHours } = event.detail; + this._updateSpecificUserDisplay(userId, newHours); + } + + _updateSpecificUserDisplay(userId, newHours) { + // Ensure userWorkloadsValue is initialized if it's not already an object + // and update it + if (typeof this.userWorkloadsValue !== 'object' || this.userWorkloadsValue === null) { + this.userWorkloadsValue = {}; + } + this.userWorkloadsValue[String(userId)] = newHours; // Ensure userId is a string key + + const avatarContainers = this.element.querySelectorAll(`.user-avatar-container[data-user-id="${userId}"]`); + avatarContainers.forEach(container => { + const hoursDisplaySpan = container.querySelector(".user-workload-hours-display"); + const nameSpan = container.querySelector("span[style^='color:']"); + + if (!nameSpan) return; // Should not happen if HTML is correct + + // Get the first text node of the nameSpan, which should be the user's name + let userName = ""; + for (let i = 0; i < nameSpan.childNodes.length; i++) { + if (nameSpan.childNodes[i].nodeType === Node.TEXT_NODE) { + userName = nameSpan.childNodes[i].textContent.trim(); + break; + } + } + + if (hoursDisplaySpan) { + if (newHours !== undefined && newHours !== null) { + hoursDisplaySpan.textContent = ` (${newHours}h)`; // Note the leading space + container.title = `${userName} (${newHours}h)`; + } else { + hoursDisplaySpan.textContent = ""; + container.title = userName; + } + } + }); + } +} diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 73c3745e8d92b36e88eabb293300a73c5a6e7e72..bdb356990bee74249408b8d74a093144b9f06302 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -8,20 +8,61 @@ class Assignment < ApplicationRecord validate :no_overlapping_standby_assignments after_create_commit :notify_assignment_created + after_create_commit :remove_candidate_if_exists # Ensure this runs to clean up candidate record + after_destroy_commit :notify_assignment_destroyed + after_destroy_commit :recreate_candidate_after_unassignment + after_destroy_commit :broadcast_session_update_on_destroy # Add this for consistency scope :future, -> { joins(:session).where("sessions.starts_at" => Time.now..) } + # This broadcast will re-render the session. + # The remove_candidate_if_exists callback should ensure data is correct before this. after_create_commit -> { - Rails.logger.debug("Created assignment, broadcasting") - broadcast_replace_to "sessions", + Rails.logger.debug("Assignment created, broadcasting session update via model callback (create)") + broadcast_replace_to stream_name_for_session, target: session, partial: "sessions/session", - locals: { session: session } + locals: { session: session.reload } } private + def remove_candidate_if_exists + Candidate.find_by(user: self.user, session: self.session)&.destroy + end + + def recreate_candidate_after_unassignment + # Only create a candidate if one doesn't already exist for this user and session + # This check is important if, for some reason, a user could be unassigned while already being a candidate. + unless Candidate.exists?(user: self.user, session: self.session) + Candidate.create(user: self.user, session: self.session) + # We might want to reload the session object if it's used immediately after in another callback, + # but the broadcast will fetch/render with fresh data. + end + end + + def broadcast_session_update_on_destroy + Rails.logger.debug("Assignment destroyed, broadcasting session update via model callback (destroy)") + # Important: self.session here refers to the session object associated with the + # assignment *before* it was destroyed. We need to ensure it's reloaded from DB + # in the partial rendering context to reflect the new candidate. + # The session.reload in the locals for the create broadcast is a good pattern. + # For destroy, the session object itself still exists. + reloaded_session = Session.find_by(id: self.session_id) # Ensure we get a fresh session + if reloaded_session + broadcast_replace_to stream_name_for_session(reloaded_session), # Pass reloaded session to stream name helper + target: reloaded_session, + partial: "sessions/session", + locals: { session: reloaded_session } + end + end + + def stream_name_for_session(current_session = self.session) + # Default to a generic name, but ideally, this should be scoped, e.g., to the conference + current_session.conference # Use the passed session's conference + end + def no_overlapping_assignments return if session.blank? || user.blank? diff --git a/app/models/user.rb b/app/models/user.rb index e0692ad7096134b0dd86bd7c56549fc131a63ac1..d0ab74349a1c4e9758f23bb04e607fae8fc12e3a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -86,7 +86,9 @@ class User < ApplicationRecord end def workload_minutes - Assignment.includes(:session).where(user: self).sum { |a| a.session.duration_minutes } + Assignment.joins(session: :conference) + .where(user: self, conferences: { active: true }) + .sum { |a| a.session.duration_minutes } end def standby_minutes diff --git a/app/views/assignments/_user_avatar.html.erb b/app/views/assignments/_user_avatar.html.erb index 02537db42b8f8fe0ef2cbf5fbde19701adf3cf99..a806f803bac92207c407b9029cdd4b40699d230b 100644 --- a/app/views/assignments/_user_avatar.html.erb +++ b/app/views/assignments/_user_avatar.html.erb @@ -1,6 +1,6 @@ <% user = assignment.user %> -<span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10" style="background-color: <%= user.avatar_color %>" title="<%= user.name %>"> - <span style="color: <%= user.text_color %>"><%= user.name %></span> +<span class="user-avatar-container inline-flex items-center gap-x-0.5 rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10" style="background-color: <%= user.avatar_color %>" title="<%= user.name %>" data-user-id="<%= user.id %>"> + <span style="color: <%= user.text_color %>"><%= user.name %> <span class="user-workload-hours-display"></span></span> <button type="button" class="only-shiftcoordinator hidden group relative -mr-1 size-3.5 rounded-sm hover:bg-gray-500/20"> <%= link_to conference_session_assignment_path(assignment.session.conference, assignment.session, assignment), data: { turbo_method: :delete, confirm: 'Are you sure?', uid: user.id }, method: :delete do %> <span class="sr-only">Remove</span> diff --git a/app/views/candidates/_user_avatar.html.erb b/app/views/candidates/_user_avatar.html.erb index a40fc17d097ec5259663c04a759408ff8363c396..26e828877bfbfb2394f2c485ad905f1af152c19c 100644 --- a/app/views/candidates/_user_avatar.html.erb +++ b/app/views/candidates/_user_avatar.html.erb @@ -1,7 +1,7 @@ <% user = candidate.user %> <% session = candidate.session %> -<span class="gap-x-0.5 rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10" style="background-color: <%= user.avatar_color %>" title="<%= user.name %>"> - <span style="color: <%= user.text_color %>"><%= user.name %></span> +<span class="user-avatar-container gap-x-0.5 rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10" style="background-color: <%= user.avatar_color %>" title="<%= user.name %>" data-user-id="<%= user.id %>"> + <span style="color: <%= user.text_color %>"><%= user.name %> <span class="user-workload-hours-display"></span></span> <button type="button" class="only-shiftcoordinator hidden group relative -mr-1 size-3.5 rounded-sm hover:bg-gray-500/20"> <%= link_to conference_session_assignments_path(session.conference, session, user_id: user.id), class: "candidate", data: { turbo_method: :post, turbo_frame: dom_id(session), uid: user.id } do %> <span class="sr-only">Add</span> diff --git a/app/views/conferences/show.html.erb b/app/views/conferences/show.html.erb index 17b60a339dbc1b5d6cbc248f839a669ca03156ed..9c3cc7d9e45cb4989dba9e21e97e4dac274cbbf4 100644 --- a/app/views/conferences/show.html.erb +++ b/app/views/conferences/show.html.erb @@ -13,7 +13,7 @@ current_time = Time.zone.now.in_time_zone(@conference.time_zone) <%= render partial: 'assignments/filteredlist_option', locals: { user: } %> <% end %> </template> -<div class="container mx-auto px-2 py-4 sm:px-4 lg:px-6 lg:py-8 text-black dark:text-white"> +<div class="container mx-auto px-2 py-4 sm:px-4 lg:px-6 lg:py-8 text-black dark:text-white" data-controller="conference-workload" data-conference-workload-user-workloads-value="<%= @user_workloads.to_json %>"> <div class="mb-4"> <a href="#now" onclick="document.querySelector('#now')?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); return false" class="underline text-blue-500">Jump to current time</a> </div>