diff --git a/Gemfile.lock b/Gemfile.lock index 9d3e689b..b748e755 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -123,7 +123,7 @@ GEM benchmark (0.5.0) bigdecimal (3.3.1) bindex (0.8.1) - bootsnap (1.25.0) + bootsnap (1.26.0) msgpack (~> 1.5) builder (3.3.0) bullet (8.2.0) @@ -419,7 +419,7 @@ GEM base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) - rack-proxy (1.0.2) + rack-proxy (2.0.0) rack (>= 2.0, < 4) rack-session (2.1.2) base64 (>= 0.1.0) @@ -758,7 +758,7 @@ CHECKSUMS benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c bigdecimal (3.3.1) sha256=eaa01e228be54c4f9f53bf3cc34fe3d5e845c31963e7fcc5bedb05a4e7d52218 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e - bootsnap (1.25.0) sha256=41059e7d0f9cb4023a33465d095f64b913fc9d1b808d6524c307da945fbcffcf + bootsnap (1.26.0) sha256=ca96237015e6cd74a02963d5821cf00ac5ea134653b323e8cd6d702a7718bf1b builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bullet (8.2.0) sha256=921ed8cde81c939e4eda041345f44f0d0d72c6fbb63aec07d11e5bf653bbe473 bundler (4.0.17) sha256=214e21431b5665dd2f99df8a5511c6b151d7a72e8015c8b38f8b775b61cbb6c1 @@ -890,7 +890,7 @@ CHECKSUMS racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac - rack-proxy (1.0.2) sha256=9f57da746be62ec3fed46646dbae2d50f7f4ddc39307d693affff9ffd431865b + rack-proxy (2.0.0) sha256=4f1d435d82afe93bc916d1226df8be307c1b808551f0ecdb56e0b668fd5756e6 rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 7ca30623..20875bbd 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -138,6 +138,7 @@ def endpoints activateProjectUrl: activate_project_path, diversityCheckUrl: check_diversity_score_path, groupsUrl: groups_path( id: '' ), + suggestGroupsUrl: suggest_groups_path( id: '' ), diversityRescoreGroup: rescore_group_path( id: '' ), diversityRescoreGroups: rescore_groups_path( id: '' ) } diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 1f893041..40d76fc7 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -5,7 +5,7 @@ class ProjectsController < ApplicationController include LtiGradable before_action :set_project, only: %i[show edit update destroy activate - rescore_group rescore_groups] + rescore_group rescore_groups suggest_groups] before_action :check_editor, except: %i[rescore_group rescore_groups show index get_groups set_groups] @@ -139,24 +139,31 @@ def set_groups group_hash = {} params[:groups].each_value do | g | group = nil - if g[:id].positive? - group = project.groups.find_by id: g[:id] + group_id = g[:id].to_i + if group_id.positive? + group = project.groups.find_by id: group_id group.name = g[:name] else group = project.groups.build( name: g[:name] ) end group.users = [] - group_hash[g[:id]] = group + group_hash[group_id] = group end params[:students].each_value do | s | student = project.rosters.find_by( user_id: s[:id] ).user - group = group_hash[s[:group_id]] + group = group_hash[s[:group_id].to_i] group.users << student unless group.nil? end begin ActiveRecord::Base.transaction do - group_hash.each_value( &:save! ) + group_hash.each_value do | group | + group.calc_diversity_score + group.save! + end + current_group_ids = group_hash.values.map( &:id ).compact + groups_to_remove = Group.where( project: ).where.not( id: current_group_ids ) + groups_to_remove.destroy_all end rescue StandardError # Post back a JSON error @@ -176,29 +183,43 @@ def get_groups get_groups_helper project: end - def get_groups_helper( project:, message: nil ) - students = {} - project.rosters.enrolled.each do | roster | - student = roster.user - students[ student.id ] = { - id: student.id, - first_name: student.first_name, - last_name: student.last_name, - email: student.email - } - end + # Builds a recommendation preview for the Groups tab without persisting it. + # + # This controller action is the bridge between the course roster and + # Group.suggest_optimal_groups. It returns the suggested groups, preview-only + # student assignments, and aggregate metrics that the React UI shows before + # the instructor accepts or rejects the proposal. + def suggest_groups + students = @project.rosters.enrolled.includes( + user: [ + :emails, :gender, :primary_language, :cip_code, + { home_state: :home_country }, { reactions: :narrative } + ] + ).map( &:user ) + suggestion = Group.suggest_optimal_groups( + users: students, + target_group_size: params[:target_group_size], + target_group_count: params[:target_group_count] + ) + students_payload = build_students_payload @project + suggested_students_payload = students_payload.deep_dup + groups_payload = build_suggested_groups_payload( suggestion, suggested_students_payload ) - groups = {} - project.groups.each do | group | - groups[group.id] = { - id: group.id, - name: group.name, - diversity: group.diversity_score + render json: { + groups: groups_payload, + students: suggested_students_payload, + summary: { + diversity_score_standard_deviation: suggestion[:diversity_score_standard_deviation], + average_diversity_score: suggestion[:average_diversity_score], + average_faultline_strength: suggestion[:average_faultline_strength], + max_faultline_strength: suggestion[:max_faultline_strength] } - group.users.each do | user | - students[user.id][ :group_id ] = group.id - end - end + } + end + + def get_groups_helper( project:, message: nil ) + students = build_students_payload project + groups = build_groups_payload( project, students ) respond_to do | format | format.json do @@ -212,7 +233,7 @@ def get_groups_helper( project:, message: nil ) end def remove_group - group = Group.find( params[:group_id] ) + group = Group.find_by( id: params[:group_id] ) group&.delete redirect_to @project end @@ -267,6 +288,63 @@ def activate private + # Serializes enrolled roster members for the groups-management JSON payloads. + def build_students_payload( project ) + students = {} + project.rosters.enrolled.each do | roster | + student = roster.user + students[ student.id ] = { + id: student.id, + first_name: student.first_name, + last_name: student.last_name, + email: student.email + } + end + students + end + + # Serializes persisted project groups and annotates the passed-in students hash + # with each member's saved group assignment. + def build_groups_payload( project, students ) + groups = {} + project.groups.each do | group | + groups[group.id] = { + id: group.id, + name: group.name, + diversity: group.diversity_score, + faultline: group.calc_faultline_strength, + member_count: group.users.count + } + group.users.each do | user | + students[user.id][ :group_id ] = group.id + end + end + groups + end + + # Serializes a recommendation preview and annotates the passed-in students hash + # with preview-only suggested group assignments. + # + # The returned structure mirrors build_groups_payload so the UI can preview a + # recommendation and later submit it through the existing set_groups action. + def build_suggested_groups_payload( suggestion, students ) + groups = {} + suggestion.fetch( :groups, [] ).each_with_index do | suggested_group, index | + group_id = -( index + 1 ) + groups[group_id] = { + id: group_id, + name: suggested_group[:name], + diversity: suggested_group[:diversity_score], + faultline: suggested_group[:faultline_strength], + member_count: suggested_group[:users].count + } + suggested_group[:users].each do | user | + students[user.id][ :group_id ] = group_id + end + end + groups + end + # Use callbacks to share common setup or constraints between actions. def set_project if params[:id].blank? || 'new' == params[:id] diff --git a/app/javascript/components/projects/ProjectDataAdmin.tsx b/app/javascript/components/projects/ProjectDataAdmin.tsx index 867e8989..496fca50 100644 --- a/app/javascript/components/projects/ProjectDataAdmin.tsx +++ b/app/javascript/components/projects/ProjectDataAdmin.tsx @@ -377,6 +377,7 @@ export default function ProjectDataAdmin(props: ProjectDataAdminProps) { { + const directionMultiplier = + direction == SortDirection.ASC ? 1 : -1; + const sortedStudents = [...studentList]; + sortedStudents.sort((studentOne, studentTwo) => { + const valueOne = studentOne[field] || ""; + const valueTwo = studentTwo[field] || ""; + if (valueOne < valueTwo) { + return -1 * directionMultiplier; + } + if (valueOne > valueTwo) { + return 1 * directionMultiplier; + } + return 0; + }); + setSortBy(field); + setSortDirection(direction); + setStudents(sortedStudents); + return sortedStudents; + }; + const addGroup = () => { const updatedGroups = Object.assign({}, groupsRaw); const group_ids = Object.keys(updatedGroups).map(Number); @@ -68,11 +95,11 @@ export default function ProjectGroups(props: Props) { studentsUpdated[item.id].group_id = null; } }); - sortStudents(sortBy, sortDirection, students); + const sortedStudents = sortStudents(sortBy, sortDirection, students); setGroupsRaw(groupsUpdated); setGroups(Object.values(groupsUpdated)); setStudentsRaw(studentsUpdated); - setStudents(students); + setStudents(sortedStudents); }; const filter = event => { @@ -82,8 +109,8 @@ export default function ProjectGroups(props: Props) { .toUpperCase() .includes(filter_text.toUpperCase()) ); - sortStudents(sortBy, sortDirection, filtered); - setStudents(filtered); + const sortedStudents = sortStudents(sortBy, sortDirection, filtered); + setStudents(sortedStudents); setFilterText(event.target.value); }; @@ -124,6 +151,10 @@ export default function ProjectGroups(props: Props) { .then(response => { const data = response.data; setWorking(false); + setSuggestedGroupsRaw(null); + setSuggestedStudentsRaw(null); + setSuggestedGroups([]); + setSuggestionSummary(null); setGroupsRaw(data.groups); setStudentsRaw(data.students); setGroups(Object.values(data.groups)); @@ -196,7 +227,7 @@ export default function ProjectGroups(props: Props) { }); }; - const saveGroups = () => { + const saveGroups = (nextGroups = groupsRaw, nextStudents = studentsRaw) => { setWorking(true); setMessage("Saving..."); @@ -204,13 +235,17 @@ export default function ProjectGroups(props: Props) { dispatch(startTask()); axios .patch(url, { - groups: groupsRaw, - students: studentsRaw + groups: nextGroups, + students: nextStudents }) .then(response => { const data = response.data; setWorking(false); setDirty(false); + setSuggestedGroupsRaw(null); + setSuggestedStudentsRaw(null); + setSuggestedGroups([]); + setSuggestionSummary(null); setGroupsRaw(data.groups); setStudentsRaw(data.students); setGroups(Object.values(data.groups)); @@ -229,6 +264,69 @@ export default function ProjectGroups(props: Props) { }); }; + const suggestGroups = () => { + if (working) { + return; + } + + setWorking(true); + setMessage("Generating recommendations..."); + + const url = props.suggestGroupsUrl + props.projectId + ".json"; + dispatch(startTask()); + axios + .post(url, { + target_group_count: targetGroupCount + }) + .then(response => { + const data = response.data; + setWorking(false); + setSuggestedGroupsRaw(data.groups); + setSuggestedStudentsRaw(data.students); + setSuggestedGroups(Object.values(data.groups)); + setSuggestionSummary(data.summary); + setMessage(""); + }) + .catch(error => { + console.log("error", error); + setWorking(false); + setMessage("Unable to generate recommendations"); + }) + .finally(() => { + dispatch(endTask()); + }); + }; + + const rejectSuggestedGroups = () => { + if (working) { + return; + } + + setSuggestedGroupsRaw(null); + setSuggestedStudentsRaw(null); + setSuggestedGroups([]); + setSuggestionSummary(null); + setMessage(""); + }; + + const acceptSuggestedGroups = () => { + if (working || null == suggestedGroupsRaw || null == suggestedStudentsRaw) { + return; + } + + saveGroups(suggestedGroupsRaw, suggestedStudentsRaw); + }; + + const suggestedMembers = groupId => { + if (null == suggestedStudentsRaw) { + return []; + } + + return Object.values(suggestedStudentsRaw).filter(student => { + return student.group_id == groupId; + }); + }; + const direction = { [SortDirection.ASC]: "asc", [SortDirection.DESC]: "desc" @@ -236,6 +334,61 @@ export default function ProjectGroups(props: Props) { return ( + {0 < suggestedGroups.length ? ( + + {0 < groups.length ? ( + + ) : null} + + {suggestedGroups.map(group => { + return ( + +
Members: {group.member_count}
+
Diversity score: {group.diversity}
+
Faultline strength: {group.faultline}
+
    + {suggestedMembers(group.id).map(student => { + return ( +
  • + {student.first_name} {student.last_name} +
  • + ); + })} +
+
+ ); + })} +
+ + +
+
+ ) : null} {dirty ? ( - ) : null} {message} + + + setTargetGroupCount(event.target.value)} + value={targetGroupCount} + disabled={working} + /> + + @@ -332,6 +499,7 @@ export default function ProjectGroups(props: Props) { rescoreGroup={rescoreGroup} students={studentsRaw} /> +
Faultline: {groupsRaw[group.id].faultline || 0}
); }} diff --git a/app/models/group.rb b/app/models/group.rb index dff4ed6d..c746eb0d 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -67,11 +67,14 @@ def self.calc_diversity_score_for_group( users: ) impairment_hash = Hash.new( 0 ) users.uniq.each do | user | - if user.home_state.present? - state_hash[user.home_state] += 1 unless - true == user.home_state_no_response - country_hash[user.home_state.home_country] += 1 unless - true == user.home_state_home_country.no_response + state = user.home_state + state = nil if state&.no_response == true + country = state&.home_country + country = nil if country&.no_response == true + + if state.present? + state_hash[state] += 1 + country_hash[country] += 1 if country.present? end cip_hash[user.cip_code] += 1 unless user.cip_code.nil? || user.cip_code_gov_code.zero? @@ -156,11 +159,275 @@ def self.calc_faultline_strength_for_group( users: ) [asw_scores.max || 0.0, 0.0].max.round( 4 ) end + # Builds a recommended partition of +users+ for the project-groups UI. + # + # This is the model-side entry point used by ProjectsController#suggest_groups. + # It converts a course roster into balanced candidate groups, evaluates those + # candidates with the existing diversity and faultline metrics, and returns + # the best-scoring proposal in a shape that can be previewed or later saved by + # the existing group-management flow. + def self.suggest_optimal_groups( users:, target_group_size: nil, target_group_count: nil ) + unique_users = users.compact.uniq do | user | + user.respond_to?( :id ) && user.id.present? ? user.id : user.object_id + end + return { groups: [], group_sizes: [] } if unique_users.count < 2 + + group_sizes = suggested_group_sizes_for( + user_count: unique_users.count, + target_group_size:, + target_group_count: + ) + return { groups: [], group_sizes: [] } if group_sizes.blank? + + random = Random.new( unique_users.count * 100 + group_sizes.count ) + groupings = suggestion_candidate_orders_for( users: unique_users, random: ) + .map do | ordered_users | + suggestion_locally_improve( + suggestion_partition_for( ordered_users, group_sizes ) + ) + end + + best_groups = groupings.min_by do | groups | + suggestion_score_for groups + end + metrics = suggestion_metrics_for best_groups + + { + group_sizes: best_groups.map( &:count ), + diversity_score_standard_deviation: metrics[:diversity_score_standard_deviation], + average_diversity_score: metrics[:average_diversity_score], + average_faultline_strength: metrics[:average_faultline_strength], + max_faultline_strength: metrics[:max_faultline_strength], + groups: best_groups.each_with_index.map do | group_users, index | + { + name: "Recommended Team #{index + 1}", + users: group_users, + diversity_score: metrics[:diversity_scores][index], + faultline_strength: metrics[:faultline_strengths][index] + } + end + } + end + private class << self private + # Chooses a feasible set of group sizes for the requested roster. + # + # This keeps every group at size >= 2 and picks the closest balanced layout + # to either the requested group count or the requested target group size. + def suggested_group_sizes_for( user_count:, target_group_size:, target_group_count: ) + return [] if user_count < 2 + + feasible_group_counts = ( 1..( user_count / 2 ) ).to_a + return [user_count] if feasible_group_counts.empty? + + group_count = if target_group_count.present? && target_group_count.to_i.positive? + requested_group_count = target_group_count.to_i + feasible_group_counts.min_by do | count | + sizes = suggestion_balanced_group_sizes_for user_count, count + [ + ( count - requested_group_count ).abs, + suggestion_size_distance_for( sizes, user_count.to_f / requested_group_count ) + ] + end + else + requested_group_size = [target_group_size.to_i, 2].max + requested_group_size = 4 if requested_group_size.zero? + feasible_group_counts.min_by do | count | + sizes = suggestion_balanced_group_sizes_for user_count, count + [ + suggestion_size_distance_for( sizes, requested_group_size ), + ( count - ( user_count.to_f / requested_group_size ) ).abs + ] + end + end + + suggestion_balanced_group_sizes_for user_count, group_count + end + + # Splits +user_count+ into as-even-as-possible sizes for +group_count+ groups. + def suggestion_balanced_group_sizes_for( user_count, group_count ) + base_size = user_count / group_count + remainder = user_count % group_count + + Array.new( group_count ) do | index | + base_size + ( index < remainder ? 1 : 0 ) + end + end + + # Scores how close a proposed set of +group_sizes+ is to +target_group_size+. + def suggestion_size_distance_for( group_sizes, target_group_size ) + [ + group_sizes.map { | size | ( size - target_group_size ).abs }.max, + group_sizes.sum { | size | ( size - target_group_size ).abs }, + group_sizes.max - group_sizes.min + ] + end + + # Produces deterministic and shuffled user orderings for the search pass. + # + # Each ordering is later partitioned into balanced groups so the search can + # compare several plausible starting points without changing persisted data. + def suggestion_candidate_orders_for( users:, random: ) + sorted_users = users.sort_by do | user | + suggestion_sort_key_for user + end + candidate_orders = [sorted_users, sorted_users.reverse] + [users.count * 2, 12].max.times do + candidate_orders << users.shuffle( random: ) + end + candidate_orders + end + + # Groups similar demographic profiles together for one candidate ordering. + def suggestion_sort_key_for( user ) + state = user.home_state + state = nil if state&.no_response == true + country = state&.home_country + country = nil if country&.no_response == true + gender_code = user.gender&.code + primary_language_code = user.primary_language&.code + cip_gov_code = user.cip_code&.gov_code + + [ + country&.id || 0, + state&.id || 0, + gender_code.to_s, + primary_language_code.to_s, + cip_gov_code || 0, + user.date_of_birth&.year || 0, + user.started_school&.year || 0, + user.object_id + ] + end + + # Partitions one ordering of users into groups with the requested sizes. + def suggestion_partition_for( ordered_users, group_sizes ) + groups = Array.new( group_sizes.count ) { [] } + remaining_slots = group_sizes.dup + + suggestion_fill_sequence_for( group_sizes ).zip( ordered_users ).each do | group_index, user | + next if user.nil? || remaining_slots[group_index].zero? + + groups[group_index] << user + remaining_slots[group_index] -= 1 + end + + groups + end + + # Spreads assignments across groups in alternating passes to balance sizes. + def suggestion_fill_sequence_for( group_sizes ) + remaining_slots = group_sizes.dup + fill_sequence = [] + index_sequence = ( 0...group_sizes.count ).to_a + + while remaining_slots.sum.positive? + index_sequence.each do | index | + next if remaining_slots[index].zero? + + fill_sequence << index + remaining_slots[index] -= 1 + end + + index_sequence.reverse_each do | index | + next if remaining_slots[index].zero? + + fill_sequence << index + remaining_slots[index] -= 1 + end + end + + fill_sequence + end + + # Improves a candidate grouping with pairwise swaps across groups. + # + # This keeps the chosen group sizes fixed while searching for a lower + # diversity-spread and faultline score than the initial partition. + def suggestion_locally_improve( groups ) + current_groups = groups.map( &:dup ) + current_score = suggestion_score_for current_groups + + [groups.count * 2, 4].max.times do + improvement_found = false + + current_groups.each_with_index do | left_group, left_index | + break if improvement_found + + ( left_index + 1...current_groups.count ).each do | right_index | + right_group = current_groups[right_index] + + left_group.each_index do | left_member_index | + break if improvement_found + + right_group.each_index do | right_member_index | + candidate_groups = current_groups.map( &:dup ) + candidate_groups[left_index][left_member_index] = right_group[right_member_index] + candidate_groups[right_index][right_member_index] = left_group[left_member_index] + candidate_score = suggestion_score_for candidate_groups + next unless -1 == ( candidate_score <=> current_score ) + + current_groups = candidate_groups + current_score = candidate_score + improvement_found = true + break + end + end + end + end + + break unless improvement_found + end + + current_groups + end + + # Orders candidate groupings using the recommendation objectives. + # + # Lower diversity-score spread and lower faultline values rank first; higher + # average diversity breaks ties in favor of stronger mixed groups. + def suggestion_score_for( groups ) + metrics = suggestion_metrics_for groups + [ + metrics[:diversity_score_standard_deviation], + metrics[:average_faultline_strength], + -metrics[:average_diversity_score], + metrics[:max_faultline_strength] + ] + end + + # Computes the aggregate metrics returned with a recommendation preview. + def suggestion_metrics_for( groups ) + diversity_scores = groups.map do | group_users | + calc_diversity_score_for_group users: group_users + end + faultline_strengths = groups.map do | group_users | + calc_faultline_strength_for_group users: group_users + end + + { + diversity_scores:, + faultline_strengths:, + diversity_score_standard_deviation: suggestion_standard_deviation_for( diversity_scores ), + average_diversity_score: diversity_scores.sum.to_f / diversity_scores.count, + average_faultline_strength: faultline_strengths.sum.to_f / faultline_strengths.count, + max_faultline_strength: faultline_strengths.max || 0.0 + } + end + + # Computes a rounded population standard deviation for recommendation scores. + def suggestion_standard_deviation_for( values ) + return 0.0 if values.count <= 1 + + average = values.sum.to_f / values.count + variance = values.sum { | value| ( value - average )**2 } / values.count + Math.sqrt( variance ).round( 4 ) + end + def faultline_profiles_for( users ) now = Date.current users.map do | user | diff --git a/config/routes.rb b/config/routes.rb index 30baa66a..e97b30cd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,6 +38,9 @@ as: :rescore_group post 'projects/rescore_groups/:id' => 'projects#rescore_groups', as: :rescore_groups + post 'projects/suggest_groups/:id' => 'projects#suggest_groups', + as: :suggest_groups, + constraints: ->(req) { req.format == :json } get 'bingo_games/activate/:bingo_game_id' => 'bingo_games#activate', as: :activate_bingo_game diff --git a/features/admin_concept.feature b/features/admin_concept.feature index 5f1c6d55..423b3520 100644 --- a/features/admin_concept.feature +++ b/features/admin_concept.feature @@ -66,7 +66,7 @@ Feature: Concept Administration Then the user logs in and accesses the "Concepts" admin page Then close all messages Then the user updates the "Conceptually Speaking" concept to "good-bye Conceptually speaking (CS)" - Then the user waits to see "successfully" + Then the user sees a success message Then the concept "Good-Bye Conceptually Speaking (CS)" will be in the list Then a concept will exist named "Good-Bye Conceptually Speaking (CS)" Then a concept will not exist named "Conceptually speaking" diff --git a/features/admin_consent_form_upload.feature b/features/admin_consent_form_upload.feature index 89d59287..343f402c 100644 --- a/features/admin_consent_form_upload.feature +++ b/features/admin_consent_form_upload.feature @@ -16,7 +16,7 @@ Feature: Admin uploads a PDF for a Consent Form When the user sets the "Name of the Study" field to "Research Study 2026" When the admin uploads a PDF for the consent form When the user clicks "Create consent form" - Then the user waits to see "successfully" + Then the user sees a success message Then the new consent form has a PDF stored in Active Storage Then the admin sees the PDF link on the consent form page @@ -28,7 +28,7 @@ Feature: Admin uploads a PDF for a Consent Form Then the user sees the consent form editing page When the admin uploads a PDF for the consent form When the user clicks "Save consent form" - Then the user waits to see "successfully" + Then the user sees a success message Then the existing consent form has a PDF stored in Active Storage Then the admin sees the PDF link on the consent form page @@ -41,7 +41,7 @@ Feature: Admin uploads a PDF for a Consent Form Then the admin sees the PDF link on the consent form page When the admin uploads a replacement PDF for the consent form When the user clicks "Save consent form" - Then the user waits to see "successfully" + Then the user sees a success message Then the existing consent form has a PDF stored in Active Storage Then the replaced PDF is different from the original PDF Then the admin sees the PDF link on the consent form page diff --git a/features/admin_experience.feature b/features/admin_experience.feature index 1d95fabe..bf957224 100644 --- a/features/admin_experience.feature +++ b/features/admin_experience.feature @@ -24,7 +24,7 @@ Feature: Experience Administration Then the user sets the "Days for instructor prep" field to "5" Then close all messages Then the user clicks "Create Experience" - And the user waits to see "success" + And the user sees a success message Then close all messages #Let's check the values stored Then retrieve the latest Experience from the db @@ -41,7 +41,7 @@ Feature: Experience Administration Then the user sets the "Experience name" field to "Jimmy Hendrix" Then close all messages Then the user clicks "Create Experience" - And the user waits to see "success" + And the user sees a success message Then close all messages #Let's check the values stored Then retrieve the latest Experience from the db @@ -61,7 +61,7 @@ Feature: Experience Administration Then the user sets the "experience" start date to "2/29/1980" and the end date to "7/10/2008" Then close all messages Then the user clicks "Create Experience" - And the user waits to see "success" + And the user sees a success message Then close all messages #Let's check the values stored Then the user sees the 'experience' 'start date' is '02/29/1980' @@ -80,7 +80,7 @@ Feature: Experience Administration Then the user sets the "experience" start date to "2/29/1980" and the end date to "7/10/2008" Then close all messages Then the user clicks "Save Experience" - And the user waits to see "success" + And the user sees a success message Then close all messages #Let's check the values stored Then retrieve the latest Experience from the db @@ -101,7 +101,7 @@ Feature: Experience Administration Then close all messages Then the user sets the "Days for instructor prep" field to "5" Then the user clicks "Save Experience" - And the user waits to see "success" + And the user sees a success message Then close all messages #Let's check the values stored Then retrieve the latest Experience from the db diff --git a/features/admin_project.feature b/features/admin_project.feature index bb8431b2..4d182549 100644 --- a/features/admin_project.feature +++ b/features/admin_project.feature @@ -30,7 +30,7 @@ Feature: Project Administration Then the user sets the "Description" field to "this is the coolest" Then close all messages Then the user clicks "Create Project" - And the user waits to see "success" + And the user sees a success message Then close all messages #Let's check the values stored Then the user sees the 'project' 'start date' is '02/29/1980' @@ -61,7 +61,7 @@ Feature: Project Administration Then the user sets the "Description" field to "this is the coolest" Then close all messages Then the user clicks "Create Project" - And the user waits to see "success" + And the user sees a success message Then close all messages #Let's check the values stored Then retrieve the latest project from the db @@ -90,7 +90,7 @@ Feature: Project Administration Then the user sets the "Description" field to "this is the coolest" Then close all messages Then the user clicks "Save Project" - And the user waits to see "success" + And the user sees a success message Then close all messages Then the user sees the 'project' 'start date' is '05/10/1976' Then the user sees the 'project' 'end date' is '02/29/1980' @@ -122,7 +122,7 @@ Feature: Project Administration Then the user sets the "Description" field to "this is the coolest" Then close all messages Then the user clicks "Create Project" - And the user waits to see "success" + And the user sees a success message Then close all messages #Let's check the values stored Then retrieve the latest project from the db @@ -144,7 +144,7 @@ Feature: Project Administration Then the user sets the "Description" field to "this is the coolest" Then close all messages Then the user clicks "Save Project" - And the user waits to see "success" + And the user sees a success message Then close all messages Then retrieve the latest project from the db #Let's check the values stored @@ -175,7 +175,7 @@ Feature: Project Administration Then the user sets the "Description" field to "this is the coolest" Then close all messages Then the user clicks "Create Project" - And the user waits to see "success" + And the user sees a success message Then close all messages Then the user sees the 'project' 'start date' is '02/29/1980' Then the user sees the 'project' 'end date' is '07/10/2008' @@ -200,7 +200,7 @@ Feature: Project Administration Then the user sets the "Description" field to "this is the coolest" Then close all messages Then the user clicks "Save Project" - And the user waits to see "success" + And the user sees a success message Then close all messages Then the user sees the 'project' 'start date' is '05/10/1976' Then the user sees the 'project' 'end date' is '02/29/1980' @@ -233,7 +233,7 @@ Feature: Project Administration Then the user sets the "Description" field to "this is the coolest" Then close all messages Then the user clicks "Create Project" - And the user waits to see "success" + And the user sees a success message Then close all messages #Let's check the values stored Then retrieve the latest project from the db @@ -255,7 +255,7 @@ Feature: Project Administration Then the user sets the "Description" field to "this is the coolest" Then close all messages Then the user clicks "Save Project" - And the user waits to see "success" + And the user sees a success message Then close all messages Then retrieve the latest project from the db #Let's check the values stored @@ -284,7 +284,7 @@ Feature: Project Administration # Then the user sets the "g_-1" field to "my group" Then close all messages Then the user clicks "Save" - And the user waits to see "success" + Then the user sees a success message Then close all messages Then the user adds a group named 'your group' # Because the above was saved, this one is -1 again @@ -294,14 +294,14 @@ Feature: Project Administration Then the user sets the "project" start date to "yesterday" and the end date to "tomorrow" Then close all messages Then the user clicks "Save" - And the user waits to see "success" + And the user sees a success message #Edit the groups Then the user switches to the "Groups" tab Then set user 1 to group "my group" Then close all messages Then the user clicks "Save" - And the user waits to see "success" + And the user sees a success message Then close all messages Then retrieve the latest project from the db Then the project "start" date is "yesterday" @@ -317,7 +317,7 @@ Feature: Project Administration Then set user 4 to group "your group" Then close all messages Then the user clicks "Save" - And the user waits to see "success" + And the user sees a success message Then close all messages Then retrieve the latest project from the db Then group "my group" has 2 user @@ -325,8 +325,68 @@ Feature: Project Administration Then group "my group" has 2 revision Then group "your group" has 1 revision + @javascript + Scenario: Instructor generates and accepts recommended groups + Given the course started "5/10/1976" and ended "4 months hence" + Given the project started "last month" and ends "next month", opened "Saturday" and closes "Monday" + Given the course started "last month" and ended "next month" + Given the user is the instructor for the course + Then the user logs in and accesses the "Courses" admin page + Then the user sees 1 course + Then the user opens the course + Then the user clicks on the existing project + Then the user switches to the "Groups" tab + Then the user requests recommended groups with target count 3 + Then the user sees 3 recommended groups + Then each recommended group has at least 2 members + Then the user sees diversity and faultline metrics for the recommended groups + Then the user accepts the recommended groups + Then close all messages + Then retrieve the latest project from the db + Then the project has 3 groups + Then every enrolled student in the course is assigned to exactly 1 project group + + @javascript + Scenario: Instructor rejects recommended groups + Given the course started "5/10/1976" and ended "4 months hence" + Given the project started "last month" and ends "next month", opened "Saturday" and closes "Monday" + Given the course started "last month" and ended "next month" + Given the user is the instructor for the course + Then the user logs in and accesses the "Courses" admin page + Then the user sees 1 course + Then the user opens the course + Then the user clicks on the existing project + Then the user switches to the "Groups" tab + Then the user requests recommended groups with target count 3 + Then the user sees 3 recommended groups + Then the user rejects the recommended groups + Then the user no longer sees the recommended groups preview + Then retrieve the latest project from the db + Then the project has 0 groups + + @javascript + Scenario: Instructor is warned before replacing existing groups with recommended groups + Given the course started "5/10/1976" and ended "4 months hence" + Given the project started "last month" and ends "next month", opened "Saturday" and closes "Monday" + Given the course started "last month" and ended "next month" + Given the user is the instructor for the course + Given the project has a group with 4 confirmed users + Then the user logs in and accesses the "Courses" admin page + Then the user sees 1 course + Then the user opens the course + Then the user clicks on the existing project + Then the user switches to the "Groups" tab + Then the user requests recommended groups with target count 3 + Then the user sees a warning that existing groups will be replaced + Then remember the recommended groups + Then the user accepts the recommended groups + Then close all messages + Then retrieve the latest project from the db + Then the original project group no longer exists + Then the remembered recommended groups exist in the project + @javascript - Scenario: Existing Sat-Mon proj=> Fri-Sat on Sat => tomorrow no emails, no access + Scenario: Existing Sat-Mon proj=> Fri-Sat on Sat => tomorrow no emails, no access Given the email queue is empty Given the project has a group with 4 confirmed users Given the user is the "a random" user in the group diff --git a/features/admin_school.feature b/features/admin_school.feature index ef6ec21c..7cd5662e 100644 --- a/features/admin_school.feature +++ b/features/admin_school.feature @@ -16,7 +16,7 @@ Feature: School Administration And the user sets the "Description" field to "I love to eat peas and carrots all day long" Then close all messages Then the user clicks "Create School" - And the user waits to see "successfully" + And the user sees a success message Then close all messages Then retrieve the latest school from the db And the school "Name" field is "hard knocks" @@ -39,7 +39,7 @@ Feature: School Administration Then close all messages Then the user clicks "Create School" #We should have success now - And the user waits to see "successfully" + And the user sees a success message Then close all messages Then retrieve the latest school from the db And the school "Name" field is "life" diff --git a/features/step_definitions/admin_project_steps.rb b/features/step_definitions/admin_project_steps.rb index 1e8fcc88..04dc25d8 100644 --- a/features/step_definitions/admin_project_steps.rb +++ b/features/step_definitions/admin_project_steps.rb @@ -257,6 +257,95 @@ Group.find_by( name: group_name ).group_revisions.count.should eq revision_count.to_i end +Then( 'the user requests recommended groups with target count {int}' ) do | target_group_count | + fill_in 'target_group_count', with: target_group_count + step 'the user clicks "Recommend Groups"' + step 'the user waits to see "Recommended Groups"' +end + +Then( 'the user sees {int} recommended groups' ) do | group_count | + page.all( '.recommended-group-card' ).count.should eq group_count +end + +Then( 'each recommended group has at least {int} members' ) do | minimum_members | + page.all( '.recommended-group-card' ).each do | card | + member_count = card.text.match( /Members:\s+(\d+)/ )[1].to_i + member_count.should be >= minimum_members + end +end + +Then( 'the user sees diversity and faultline metrics for the recommended groups' ) do + page.should have_content 'Diversity score std. dev.' + page.should have_content 'Average diversity score' + page.should have_content 'Average faultline strength' + page.should have_content 'Max faultline strength' + page.all( '.recommended-group-card' ).each do | card | + card.text.should include 'Diversity score:' + card.text.should include 'Faultline strength:' + end +end + +Then( 'the user accepts the recommended groups' ) do + step 'the user clicks "Accept Suggested Groups"' + step 'the user sees a success message' +end + +Then( 'the user rejects the recommended groups' ) do + step 'the user clicks "Reject Suggested Groups"' +end + +Then( 'the user no longer sees the recommended groups preview' ) do + page.should have_no_content 'Recommended Groups' +end + +Then( 'the user sees a warning that existing groups will be replaced' ) do + page.should have_content 'Accepting these suggested groups will remove and replace the existing project groups.' +end + +Then( 'remember the recommended groups' ) do + @recommended_groups = page.all( '.recommended-group-card' ).map do | card | + { + name: card.find( '.p-panel-title' ).text, + members: card.all( 'li' ).map { | item| item.text } + } + end +end + +Then( 'every enrolled student in the course is assigned to exactly {int} project group' ) do | group_count | + appearance_counts = Hash.new( 0 ) + @project.reload.groups.includes( :users ).each do | group | + group.users.each do | user | + appearance_counts[user.id] += 1 + end + end + + @course.rosters.enrolled.each do | roster | + appearance_counts[roster.user_id].should eq group_count + end +end + +Then( 'the original project group no longer exists' ) do + Group.find_by( id: @group.id ).should be_nil +end + +Then( 'the remembered recommended groups exist in the project' ) do + actual_groups = @project.reload.groups.includes( :users ).map do | group | + { + name: group.name, + members: group.users.map { | user| "#{user.first_name} #{user.last_name}" }.sort + } + end + + expected_groups = @recommended_groups.map do | group | + { + name: group[:name], + members: group[:members].sort + } + end + + actual_groups.sort_by { | group| group[:name] }.should eq expected_groups.sort_by { | group| group[:name] } +end + Then( 'the user selects the {string} menu item' ) do | menu_item | search_path = "//*[@id='#{menu_item.downcase}-menu-item']" find( :xpath, "//*[@id='administration-menu']" ).click unless has_xpath?( search_path ) diff --git a/package.json b/package.json index 4924525e..2c760bd9 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "@rspack/core": "^2.2.2", "@rspack/dev-server": "^2.2.1", "@rspack/plugin-react-refresh": "^2.0.2", - "@swc/core": "^1.16.1", + "@swc/core": "^1.16.2", "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.5", + "@types/react-dom": "^19.2.7", "@types/react-redux": "^7.1.34", "@types/stopword": "^2.0.3", "@visx/axis": "^4.0.0", @@ -40,7 +40,7 @@ "driver.js": "^1.8.0", "email-validator": "^2.0.4", "html-react-parser": "^6.1.7", - "i18next": "^26.4.1", + "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", "i18next-fetch-backend": "^8.0.0", "i18next-intervalplural-postprocessor": "^3.0.0", diff --git a/test/models/group_test.rb b/test/models/group_test.rb index 67f14155..ac354cd5 100644 --- a/test/models/group_test.rb +++ b/test/models/group_test.rb @@ -72,6 +72,80 @@ class GroupTest < ActiveSupport::TestCase assert_equal 0.0, Group.calc_faultline_strength_for_proposed_group( emails: ' , , ' ) end + test 'suggest optimal groups assigns each user exactly once with balanced sizes' do + users = [ + faultline_user( group: :a, id: 1 ), + faultline_user( group: :a, id: 2 ), + faultline_user( group: :a, id: 3 ), + faultline_user( group: :b, id: 4 ), + faultline_user( group: :b, id: 5 ), + faultline_user( group: :b, id: 6 ), + faultline_user( group: :b, id: 7 ) + ] + + suggestion = Group.suggest_optimal_groups( users:, target_group_size: 3 ) + + suggested_users = suggestion[:groups].flat_map { | group| group[:users] } + suggested_user_ids = suggested_users.map( &:id ).sort + + assert_equal users.map( &:id ).sort, suggested_user_ids + assert_equal users.count, suggested_users.count + assert_equal [3, 4], suggestion[:group_sizes].sort + assert suggestion[:groups].all? { | group| group[:users].count >= 2 } + assert suggestion[:groups].all? { | group| group.key?( :diversity_score ) } + assert suggestion[:groups].all? { | group| group.key?( :faultline_strength ) } + end + + test 'suggest optimal groups honors requested target group count when feasible' do + users = 9.times.map do | index | + faultline_user( group: index < 4 ? :a : :b, id: index + 1 ) + end + + suggestion = Group.suggest_optimal_groups( users:, target_group_count: 4 ) + + assert_equal 4, suggestion[:groups].count + assert_equal [2, 2, 2, 3], suggestion[:group_sizes].sort + end + + test 'suggest optimal groups falls back to the nearest feasible group count' do + users = 5.times.map do | index | + faultline_user( group: index < 2 ? :a : :b, id: index + 1 ) + end + + suggestion = Group.suggest_optimal_groups( users:, target_group_count: 3 ) + + assert_equal 2, suggestion[:groups].count + assert_equal [2, 3], suggestion[:group_sizes].sort + end + + test 'suggest optimal groups handles nil demographics without delegation errors' do + users = [ + faultline_user_without_demographics( id: 1 ), + faultline_user_without_demographics( id: 2 ), + faultline_user_without_demographics( id: 3 ), + faultline_user_without_demographics( id: 4 ) + ] + + assert_nothing_raised do + suggestion = Group.suggest_optimal_groups( users:, target_group_count: 2 ) + assert_equal [2, 2], suggestion[:group_sizes].sort + end + end + + test 'suggest optimal groups handles states without countries' do + users = [ + faultline_user_with_state_without_country( id: 1 ), + faultline_user_with_state_without_country( id: 2 ), + faultline_user_without_demographics( id: 3 ), + faultline_user_without_demographics( id: 4 ) + ] + + assert_nothing_raised do + suggestion = Group.suggest_optimal_groups( users:, target_group_count: 2 ) + assert_equal [2, 2], suggestion[:group_sizes].sort + end + end + private class MockFaultlineUserRelation @@ -93,7 +167,7 @@ def includes( *_args ) end end - def faultline_user( group: ) + def faultline_user( group:, id: nil ) country_id = ( group == :a ? 1 : 2 ) state_id = ( group == :a ? 11 : 22 ) gender_id = ( group == :a ? 101 : 202 ) @@ -104,6 +178,7 @@ def faultline_user( group: ) birth_year = ( group == :a ? 2004 : 1997 ) OpenStruct.new( + id:, home_state: OpenStruct.new( id: state_id, home_country: OpenStruct.new( id: country_id, no_response: false ) @@ -126,8 +201,9 @@ def faultline_user( group: ) ) end - def faultline_user_without_demographics + def faultline_user_without_demographics( id: nil ) OpenStruct.new( + id:, home_state: nil, cip_code: nil, gender: nil, @@ -143,4 +219,23 @@ def faultline_user_without_demographics started_school?: false ) end + + def faultline_user_with_state_without_country( id: nil ) + OpenStruct.new( + id:, + home_state: OpenStruct.new( id: 99, no_response: false, home_country: nil ), + cip_code: nil, + gender: nil, + primary_language: nil, + impairment_visual: false, + impairment_auditory: false, + impairment_motor: false, + impairment_cognitive: false, + impairment_other: false, + date_of_birth: nil, + started_school: nil, + date_of_birth?: false, + started_school?: false + ) + end end diff --git a/yarn.lock b/yarn.lock index fa6089f4..d8be318b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -660,106 +660,106 @@ __metadata: languageName: node linkType: hard -"@swc/core-darwin-arm64@npm:1.16.1": - version: 1.16.1 - resolution: "@swc/core-darwin-arm64@npm:1.16.1" +"@swc/core-darwin-arm64@npm:1.16.2": + version: 1.16.2 + resolution: "@swc/core-darwin-arm64@npm:1.16.2" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@swc/core-darwin-x64@npm:1.16.1": - version: 1.16.1 - resolution: "@swc/core-darwin-x64@npm:1.16.1" +"@swc/core-darwin-x64@npm:1.16.2": + version: 1.16.2 + resolution: "@swc/core-darwin-x64@npm:1.16.2" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@swc/core-linux-arm-gnueabihf@npm:1.16.1": - version: 1.16.1 - resolution: "@swc/core-linux-arm-gnueabihf@npm:1.16.1" +"@swc/core-linux-arm-gnueabihf@npm:1.16.2": + version: 1.16.2 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.16.2" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@swc/core-linux-arm64-gnu@npm:1.16.1": - version: 1.16.1 - resolution: "@swc/core-linux-arm64-gnu@npm:1.16.1" +"@swc/core-linux-arm64-gnu@npm:1.16.2": + version: 1.16.2 + resolution: "@swc/core-linux-arm64-gnu@npm:1.16.2" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-arm64-musl@npm:1.16.1": - version: 1.16.1 - resolution: "@swc/core-linux-arm64-musl@npm:1.16.1" +"@swc/core-linux-arm64-musl@npm:1.16.2": + version: 1.16.2 + resolution: "@swc/core-linux-arm64-musl@npm:1.16.2" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@swc/core-linux-ppc64-gnu@npm:1.16.1": - version: 1.16.1 - resolution: "@swc/core-linux-ppc64-gnu@npm:1.16.1" +"@swc/core-linux-ppc64-gnu@npm:1.16.2": + version: 1.16.2 + resolution: "@swc/core-linux-ppc64-gnu@npm:1.16.2" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-s390x-gnu@npm:1.16.1": - version: 1.16.1 - resolution: "@swc/core-linux-s390x-gnu@npm:1.16.1" +"@swc/core-linux-s390x-gnu@npm:1.16.2": + version: 1.16.2 + resolution: "@swc/core-linux-s390x-gnu@npm:1.16.2" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@swc/core-linux-x64-gnu@npm:1.16.1": - version: 1.16.1 - resolution: "@swc/core-linux-x64-gnu@npm:1.16.1" +"@swc/core-linux-x64-gnu@npm:1.16.2": + version: 1.16.2 + resolution: "@swc/core-linux-x64-gnu@npm:1.16.2" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-x64-musl@npm:1.16.1": - version: 1.16.1 - resolution: "@swc/core-linux-x64-musl@npm:1.16.1" +"@swc/core-linux-x64-musl@npm:1.16.2": + version: 1.16.2 + resolution: "@swc/core-linux-x64-musl@npm:1.16.2" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@swc/core-win32-arm64-msvc@npm:1.16.1": - version: 1.16.1 - resolution: "@swc/core-win32-arm64-msvc@npm:1.16.1" +"@swc/core-win32-arm64-msvc@npm:1.16.2": + version: 1.16.2 + resolution: "@swc/core-win32-arm64-msvc@npm:1.16.2" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@swc/core-win32-ia32-msvc@npm:1.16.1": - version: 1.16.1 - resolution: "@swc/core-win32-ia32-msvc@npm:1.16.1" +"@swc/core-win32-ia32-msvc@npm:1.16.2": + version: 1.16.2 + resolution: "@swc/core-win32-ia32-msvc@npm:1.16.2" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@swc/core-win32-x64-msvc@npm:1.16.1": - version: 1.16.1 - resolution: "@swc/core-win32-x64-msvc@npm:1.16.1" +"@swc/core-win32-x64-msvc@npm:1.16.2": + version: 1.16.2 + resolution: "@swc/core-win32-x64-msvc@npm:1.16.2" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@swc/core@npm:^1.16.1": - version: 1.16.1 - resolution: "@swc/core@npm:1.16.1" +"@swc/core@npm:^1.16.2": + version: 1.16.2 + resolution: "@swc/core@npm:1.16.2" dependencies: - "@swc/core-darwin-arm64": "npm:1.16.1" - "@swc/core-darwin-x64": "npm:1.16.1" - "@swc/core-linux-arm-gnueabihf": "npm:1.16.1" - "@swc/core-linux-arm64-gnu": "npm:1.16.1" - "@swc/core-linux-arm64-musl": "npm:1.16.1" - "@swc/core-linux-ppc64-gnu": "npm:1.16.1" - "@swc/core-linux-s390x-gnu": "npm:1.16.1" - "@swc/core-linux-x64-gnu": "npm:1.16.1" - "@swc/core-linux-x64-musl": "npm:1.16.1" - "@swc/core-win32-arm64-msvc": "npm:1.16.1" - "@swc/core-win32-ia32-msvc": "npm:1.16.1" - "@swc/core-win32-x64-msvc": "npm:1.16.1" + "@swc/core-darwin-arm64": "npm:1.16.2" + "@swc/core-darwin-x64": "npm:1.16.2" + "@swc/core-linux-arm-gnueabihf": "npm:1.16.2" + "@swc/core-linux-arm64-gnu": "npm:1.16.2" + "@swc/core-linux-arm64-musl": "npm:1.16.2" + "@swc/core-linux-ppc64-gnu": "npm:1.16.2" + "@swc/core-linux-s390x-gnu": "npm:1.16.2" + "@swc/core-linux-x64-gnu": "npm:1.16.2" + "@swc/core-linux-x64-musl": "npm:1.16.2" + "@swc/core-win32-arm64-msvc": "npm:1.16.2" + "@swc/core-win32-ia32-msvc": "npm:1.16.2" + "@swc/core-win32-x64-msvc": "npm:1.16.2" "@swc/counter": "npm:^0.1.3" "@swc/types": "npm:^0.1.28" peerDependencies: @@ -792,7 +792,7 @@ __metadata: peerDependenciesMeta: "@swc/helpers": optional: true - checksum: 10c0/6711717ac132d7feba331d70d266faec2197951531b1d4faaaa4ceee415f4739c8c4b5c18a48fc04fee084df37c5713b395eb91f024707efdd0aff6d155748ae + checksum: 10c0/8019735e27852f03b0e09ae7f8c3f00e6a2e3f49b72127e09c20cffde335fe3b776afb20bfd3b3f5c540d42667f1b7467a4e10aa11c50ff59c6f86a9513544a8 languageName: node linkType: hard @@ -991,12 +991,12 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:^19.2.5": - version: 19.2.5 - resolution: "@types/react-dom@npm:19.2.5" +"@types/react-dom@npm:^19.2.7": + version: 19.2.7 + resolution: "@types/react-dom@npm:19.2.7" peerDependencies: "@types/react": ^19.2.0 - checksum: 10c0/7a59467b043debf392d109daa1ba672e791f20ce6f24ae81fb54715f890f8119d24967e61e3644b6307428603b695b7d59e69eaf531bd1963a74a6cb462f5f49 + checksum: 10c0/226ab7e2b62114125e2276b3dcc3a25060177a673f8abadb7e31f395003de72ad68075c6edc5f51b02dd1e47443920ee030239c474c47fad075ded7410836909 languageName: node linkType: hard @@ -1619,12 +1619,12 @@ __metadata: "@rspack/core": "npm:^2.2.2" "@rspack/dev-server": "npm:^2.2.1" "@rspack/plugin-react-refresh": "npm:^2.0.2" - "@swc/core": "npm:^1.16.1" + "@swc/core": "npm:^1.16.2" "@types/d3-array": "npm:^3.2.2" "@types/d3-time-format": "npm:^4.0.3" "@types/react": "npm:^19.2.18" "@types/react-big-calendar": "npm:^1.16.3" - "@types/react-dom": "npm:^19.2.5" + "@types/react-dom": "npm:^19.2.7" "@types/react-redux": "npm:^7.1.34" "@types/stopword": "npm:^2.0.3" "@visx/axis": "npm:^4.0.0" @@ -1646,7 +1646,7 @@ __metadata: driver.js: "npm:^1.8.0" email-validator: "npm:^2.0.4" html-react-parser: "npm:^6.1.7" - i18next: "npm:^26.4.1" + i18next: "npm:^26.4.2" i18next-browser-languagedetector: "npm:^8.2.1" i18next-fetch-backend: "npm:^8.0.0" i18next-intervalplural-postprocessor: "npm:^3.0.0" @@ -2512,15 +2512,15 @@ __metadata: languageName: node linkType: hard -"i18next@npm:^26.4.1": - version: 26.4.1 - resolution: "i18next@npm:26.4.1" +"i18next@npm:^26.4.2": + version: 26.4.2 + resolution: "i18next@npm:26.4.2" peerDependencies: typescript: ^5 || ^6 || ^7 peerDependenciesMeta: typescript: optional: true - checksum: 10c0/d378fdef07d07b3bbaa803ca18bf663bfcb203ab4be5eb8da2f1eb49e5559efcaf731f0cf34deccfe804c465f1cab8948d58f9303afe6921bc18949cb170df5e + checksum: 10c0/f2c9613e03e09e56a84aee939d8fbe3442a58622915e11f97e47f9ab358dfa65be783230b37a21483152711f786fccfe697d7f5ea9269c54f16179dc59960e4b languageName: node linkType: hard