From 3df2181df8d18ddcc45c6f27941e639c75d28422 Mon Sep 17 00:00:00 2001 From: benmelz Date: Fri, 13 Mar 2026 14:22:54 -0400 Subject: [PATCH 01/11] migrate to continuous assignments --- ...60313163717_setup_continuous_assignment.rb | 72 +++++++++++++++++++ db/schema.rb | 9 +-- 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20260313163717_setup_continuous_assignment.rb diff --git a/db/migrate/20260313163717_setup_continuous_assignment.rb b/db/migrate/20260313163717_setup_continuous_assignment.rb new file mode 100644 index 00000000..f0d7827f --- /dev/null +++ b/db/migrate/20260313163717_setup_continuous_assignment.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class SetupContinuousAssignment < ActiveRecord::Migration[8.1] + class Roster < ActiveRecord::Base + end + + class Assignment < ActiveRecord::Base + end + + def change + change_table :assignments do |t| + t.datetime :end_datetime + t.index :end_datetime + t.index %i[roster_id end_datetime], unique: true + t.change_null :user_id, true + t.change_null :start_date, true + t.change_null :end_date, true + end + + reversible do |dir| + dir.up do + Roster.find_each do |roster| + assignments = Assignment.where(roster_id: roster.id).order(start_date: :asc) + total_start_date = assignments.first.start_date + + # create "anchor" assignment + prev = Assignment.create!(roster_id: roster.id, + user_id: nil, + end_date: total_start_date - 1, + end_datetime: total_start_date + roster.switchover.minutes) + + assignments.where.not(id: prev).in_batches do |batch| + batch.each do |curr| + # if this assignment was not continuous with the previous, create an empty assignment to fill gap + if curr.start_date != prev.end_date + 1 + Assignment.create!(roster_id: roster.id, + user_id: nil, + end_datetime: curr.start_date + roster.switchover.minutes) + end + + # write end datetime and update pointer + curr.update!(end_datetime: curr.end_date + 1.day + roster.switchover.minutes) + prev = curr + end + end + end + end + + dir.down do + Roster.find_each do |roster| + assignments = Assignment.where(roster_id: roster.id).order(end_datetime: :asc) + + prev = assignments.first + + assignments.where.not(id: prev).in_batches do |batch| + batch.each do |curr| + # write dates and update pointer + curr.update!(start_date: prev.end_datetime, end_date: curr.end_datetime - 1.day) + prev = curr + end + end + + assignments.where(user_id: nil).find_each(&:destroy!) + end + end + end + + change_column_null :assignments, :end_datetime, false + remove_column :assignments, :start_date, :date + remove_column :assignments, :end_date, :date + end +end diff --git a/db/schema.rb b/db/schema.rb index 8d31f5ff..8e6c7c3d 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.1].define(version: 2026_03_07_175220) do +ActiveRecord::Schema[8.1].define(version: 2026_03_13_163717) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -41,11 +41,12 @@ create_table "assignments", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false - t.date "end_date", null: false + t.datetime "end_datetime", null: false t.bigint "roster_id", null: false - t.date "start_date", null: false t.datetime "updated_at", precision: nil, null: false - t.bigint "user_id", null: false + t.bigint "user_id" + t.index ["end_datetime"], name: "index_assignments_on_end_datetime" + t.index ["roster_id", "end_datetime"], name: "index_assignments_on_roster_id_and_end_datetime", unique: true t.index ["roster_id"], name: "index_assignments_on_roster_id" t.index ["user_id"], name: "index_assignments_on_user_id" end From 1aebc37bb253a8f85f5c1536ece8c509b168456d Mon Sep 17 00:00:00 2001 From: benmelz Date: Thu, 19 Mar 2026 12:41:29 -0400 Subject: [PATCH 02/11] barebones model revamp --- app/models/assignment.rb | 144 +++------------- spec/factories/assignments.rb | 4 +- spec/models/assignment_spec.rb | 305 ++++++++++----------------------- 3 files changed, 117 insertions(+), 336 deletions(-) diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 8e0f1d35..add62985 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -2,142 +2,40 @@ class Assignment < ApplicationRecord has_paper_trail - belongs_to :user - belongs_to :roster - validates :start_date, presence: true - validates :end_date, presence: true, - comparison: { greater_than_or_equal_to: :start_date, - if: -> { start_date.present? && end_date.present? }, - message: :must_not_be_before_start } - validate :overlaps_none - validate :user_in_roster + attribute :start_datetime, :datetime + + belongs_to :roster + belongs_to :user, optional: true - after_commit :notify_user_of_assignment - after_commit :notify_user_of_change - after_commit :notify_user_of_removal + validates :end_datetime, presence: true, uniqueness: { scope: :roster_id } - scope :future, -> { where 'start_date > ?', Time.zone.today } - scope :at, ->(time) { joins(:roster).where(start_time_node.lteq(time)).where(end_time_node.gt(time)) } + def start_datetime = super.presence || previous&.end_datetime || roster.created_at - def effective_start_datetime - start_date + roster.switchover.minutes + def previous + roster.assignments + .where(self.class.arel_table[:end_datetime].lt(end_datetime)) + .order(end_datetime: :desc).first end - # Assignments effectively end at the switchover hour on the following day. - def effective_end_datetime - end_date + 1.day + roster.switchover.minutes + def next + roster.assignments + .where(self.class.arel_table[:end_datetime].gt(end_datetime)) + .order(end_datetime: :asc).first end class << self - def between(start_date, end_date) - where arel_table[:start_date].lteq(end_date).and(arel_table[:end_date].gteq(start_date)) - end - - # The current assignment - this method accounts for the switchover hour. - # This should be called while scoped to a particular roster. - def current - joins(:roster).on(effective_date) - end - - def effective_date - switchover = Roster.arel_table[:switchover] - yesterday = Arel::Nodes.build_quoted(Date.yesterday) - today = Arel::Nodes.build_quoted(Time.zone.today) - - # If it's after the roster's switchover, use "Today", otherwise it's still "Yesterday". - # e.g. IF(1020 >= `roster`.`switchover`, '2023-09-01', '2023-08-31') - Arel::Nodes::NamedFunction.new('IF', [minutes_since_midnight.gteq(switchover), today, yesterday]) - end - - def start_time_node = time_node(:start_date) - - def end_time_node - Arel::Nodes::NamedFunction.new 'TIMESTAMPADD', - [Arel::Nodes::SqlLiteral.new('DAY'), - Arel::Nodes::SqlLiteral.new('1'), - time_node(:end_date)] - end - - def in(roster) - where roster: - end - - # returns the assignment which takes place on a particular date - def on(date) - between(date, date).first - end - - def upcoming - joins(:roster).where arel_table[:start_date].gt(effective_date) - end - - def send_reminders! - where(start_date: Date.tomorrow).find_each do |assignment| - AssignmentsMailer.upcoming_reminder(assignment.roster, assignment.effective_start_datetime, - assignment.effective_end_datetime, assignment.user).deliver_now - end + def with_start_datetimes + from arel_table.project(arel_table[Arel.star], start_datetime_node).as(arel_table.name) end private - def time_node(column) - Arel::Nodes::NamedFunction.new 'TIMESTAMPADD', - [Arel::Nodes::SqlLiteral.new('MINUTE'), - Roster.arel_table[:switchover], - arel_table[column]] + def start_datetime_node + Arel::Nodes::Over.new( + Arel::Nodes::NamedFunction.new('LAG', [arel_table[:end_datetime]]), + Arel::Nodes::Window.new.partition(arel_table[:roster_id]).order(arel_table[:end_datetime]) + ).as(arel_table[:start_datetime].name) end end - - private - - def overlaps_none - # A non-new record always overlaps itself, so we exclude it from our query. - return if roster.assignments - .where('`start_date` <= ? AND `end_date` >= ?', end_date, start_date) - .excluding(self) - .none? - - errors.add :base, 'Overlaps with another assignment' - end - - def notify_user_of_assignment - return unless user_id_previously_changed? - return if user == Current.user - return unless user.change_notifications_enabled? - - AssignmentsMailer.new_assignment(roster, effective_start_datetime, effective_end_datetime, user, Current.user) - .deliver_later - end - - def notify_user_of_change - return if previously_new_record? - return if user_id_previously_changed? - return unless start_date_previously_changed? || end_date_previously_changed? - return if user == Current.user - return unless user.change_notifications_enabled? - - AssignmentsMailer.changed_assignment(roster, effective_start_datetime, effective_end_datetime, user, Current.user) - .deliver_later - end - - def notify_user_of_removal - return unless previously_persisted? || user_id_previously_changed? - - previous_user = previously_persisted? ? user : User.find_by(id: user_id_previously_was) - return if previous_user.blank? - return if previous_user == Current.user - return unless previous_user.change_notifications_enabled? - - AssignmentsMailer.deleted_assignment(roster, - effective_start_datetime, effective_end_datetime, - previous_user, Current.user) - .deliver_later - end - - def user_in_roster - return if roster.users.include? user - - errors.add :base, 'User is not in this roster' - end end diff --git a/spec/factories/assignments.rb b/spec/factories/assignments.rb index fa3ef655..1f688c32 100644 --- a/spec/factories/assignments.rb +++ b/spec/factories/assignments.rb @@ -3,8 +3,6 @@ FactoryBot.define do factory :assignment do roster - user { association :user, rosters: [roster] } - start_date { Date.yesterday } - end_date { Date.tomorrow } + sequence(:end_datetime) { |n| Date.current + n.days } end end diff --git a/spec/models/assignment_spec.rb b/spec/models/assignment_spec.rb index fbd21087..c116b25c 100644 --- a/spec/models/assignment_spec.rb +++ b/spec/models/assignment_spec.rb @@ -5,261 +5,146 @@ RSpec.describe Assignment do include ActiveSupport::Testing::TimeHelpers - describe 'effective time methods' do - let(:roster) { create :roster, switchover: 14 * 60 } - let :assignment do - create :assignment, roster:, - start_date: Date.new(2017, 4, 10), end_date: Date.new(2017, 4, 11) - end - - describe '#effective_start_datetime' do - it 'returns the start date, at the switchover hour' do - expect(assignment.effective_start_datetime) - .to eql Time.zone.local(2017, 4, 10, 14) - end - end - - describe '#effective_end_datetime' do - it 'returns the day after the end date, at the switchover hour' do - expect(assignment.effective_end_datetime) - .to eql Time.zone.local(2017, 4, 12, 14) - end - end + describe 'associations' do + it { is_expected.to belong_to(:roster) } + it { is_expected.to belong_to(:user).optional } end - describe '.between' do - let(:roster) { create :roster } - - let!(:assignment) do - create :assignment, roster:, start_date: Time.zone.today, end_date: 5.days.from_now - end + describe 'validations' do + subject { build :assignment } - it 'returns overlapping assignment range' do - result = described_class.between(Time.zone.today + 2, Time.zone.today + 3) - expect(result).to include(assignment) - end + it { is_expected.to validate_presence_of(:end_datetime) } + it { is_expected.to validate_uniqueness_of(:end_datetime).scoped_to(:roster_id) } end - describe '.current' do - subject(:call) { described_class.current } + describe '#previous' do + subject(:call) { assignment.previous } let(:roster) { create :roster } - let! :yesterday do - date = Date.new(2019, 11, 12) - create :assignment, start_date: date, end_date: date, roster: - end - let! :today do - date = Date.new(2019, 11, 13) - create :assignment, start_date: date, end_date: date, roster: - end - let :switchover_time do - Date.new(2019, 11, 13) + roster.switchover.minutes - end + let(:assignment) { build_stubbed(:assignment, roster:) } - context 'when it is before the switchover hour' do - it "returns yesterday's assignment" do - travel_to 1.minute.before(switchover_time) - expect(call).to eq yesterday - end - end - - context 'when it is after the switchover hour' do - it "returns today's assignment" do - travel_to 1.minute.after(switchover_time) - expect(call).to eq today - end - end - - context 'with assignments in multiple rosters' do - before do - travel_to 1.minute.after(switchover_time) - end - - let! :new_assignment do - # This new assignment will also belong to a new roster - create :assignment, start_date: Time.zone.today, end_date: Time.zone.today - end - - it 'includes assignments in the current roster' do - expect(roster.assignments.current).to eq today - end - - it 'includes assignments in the other roster when called on the other roster' do - expect(new_assignment.roster.assignments.current).to eq new_assignment - end + before do + create(:assignment, roster:, end_datetime: assignment.end_datetime + 10.minutes) + create(:assignment, roster:, end_datetime: assignment.end_datetime + 5.minutes) + create :assignment, end_datetime: assignment.end_datetime - 5.minutes end - end - - describe '#save' do - subject(:save) { assignment.save } - - let(:assignment) { create :assignment } - let(:recipient) { assignment.user } - let(:current_user) { create :user } - - context 'when the changer is the recipient' do - let(:current_user) { recipient } - it 'does not send an email' do - expect { save }.not_to have_enqueued_email(AssignmentsMailer, :changed_assignment) - end + context 'when there are no other assignments that have the same roster and an earlier end datetime' do + it { is_expected.to be_nil } end - context 'when the changer is not the recipient' do - context 'when creating a new assignment' do - it 'sends the new_assignment mail' do - expect { create :assignment }.to have_enqueued_email(AssignmentsMailer, :new_assignment) - end - end - - context 'when updating an assignment' do - before { assignment.assign_attributes start_date: 1.week.from_now, end_date: 2.weeks.from_now } - - it 'sends the changed_assignment mail' do - expect { save }.to have_enqueued_email(AssignmentsMailer, :changed_assignment) - end - end - end + context 'when there are other assignments that have the same roster and an earlier end datetime' do + let!(:target) { create(:assignment, roster:, end_datetime: assignment.end_datetime - 5.minutes) } - context 'when change notifications are disabled' do - before { recipient.update change_notifications_enabled: false } + before { create(:assignment, roster:, end_datetime: assignment.end_datetime - 10.minutes) } - it 'does not send notifications' do - expect { save }.not_to have_enqueued_email(AssignmentsMailer, :changed_assignment) + it 'returns the one with the latest end datetime' do + expect(call).to eq(target) end end end - describe '#destroy' do - subject(:destroy) { assignment.destroy } - - let(:assignment) { create :assignment } - - it 'sends the deleted_assignment mail' do - expect { destroy }.to have_enqueued_email(AssignmentsMailer, :deleted_assignment) - end - end + describe '#next' do + subject(:call) { assignment.next } - describe '.in(roster)' do let(:roster) { create :roster } - let!(:assignment) { create :assignment, roster: } - - it 'returns assignment in the given roster' do - expect(described_class.in(roster)).to include(assignment) - end - end - - describe '.on' do - subject(:call) { described_class.on date } - - let(:date) { Date.new(2019, 11, 13) } - let! :correct_assignment do - create :assignment, start_date: date, end_date: 6.days.after(date) - end + let(:assignment) { build_stubbed(:assignment, roster:) } before do - create :assignment, start_date: 1.week.before(date), end_date: 1.day.before(date) - create :assignment, start_date: 1.week.after(date), end_date: 13.days.after(date) + create :assignment, end_datetime: assignment.end_datetime + 5.minutes + create(:assignment, roster:, end_datetime: assignment.end_datetime - 5.minutes) + create(:assignment, roster:, end_datetime: assignment.end_datetime - 10.minutes) end - it { is_expected.to eq correct_assignment } - end - - describe 'overlapping assignment validation' do - let(:roster) { create :roster } - - before do - create :assignment, roster:, - start_date: Time.zone.today, - end_date: 6.days.from_now + context 'when there are no other assignments that have the same roster and a later end datetime' do + it { is_expected.to be_nil } end - context 'when creating assignments that do not overlap' do - it 'does not add errors' do - assignments = [[1.week.ago, Date.yesterday], - [1.week.from_now, 2.weeks.from_now]].map do |s, e| - create :assignment, start_date: s, end_date: e, roster: roster - end - expect(assignments).to all(be_valid) - end - end + context 'when there are other assignments that have the same roster and a later end datetime' do + let!(:target) { create(:assignment, roster:, end_datetime: assignment.end_datetime + 5.minutes) } - context 'with an overlapping assignment in the same roster' do - it 'adds errors' do - assignment = build :assignment, - roster: roster, - start_date: Date.yesterday, - end_date: Date.tomorrow - expect(assignment).not_to be_valid + before { create(:assignment, roster:, end_datetime: assignment.end_datetime + 10.minutes) } + + it 'returns the one with the earliest end datetime' do + expect(call).to eq(target) end end end - describe '.upcoming' do - subject { described_class.upcoming } + describe '#start_datetime' do + subject(:call) { assignment.start_datetime } - let(:roster) { create :roster } - let :assignment_today do - create :assignment, roster:, - start_date: Time.zone.today, end_date: 1.week.since.to_date - end - let :assignment_tomorrow do - create :assignment, roster:, - start_date: Date.tomorrow, end_date: 1.week.since.to_date - end + context 'when the attribute has been previously set' do + let(:assignment) { build_stubbed :assignment, start_datetime: value } + let(:value) { Time.current } - context 'when it is before the switchover' do - before do - travel_to 1.minute.before(roster.switchover_time) + it 'returns the previously set value' do + expect(call).to eq(value) end - - it { is_expected.to include assignment_today } - it { is_expected.to include assignment_tomorrow } end - context 'when it is after the switchover' do + context 'when the assignment has a predecessor' do + let(:roster) { create :roster } + let(:assignment) { build_stubbed(:assignment, roster:) } + let!(:predecessor) { create(:assignment, roster:, end_datetime: assignment.end_datetime - 5.minutes) } + before do - travel_to 1.minute.after(roster.switchover_time) + create(:assignment, roster:, end_datetime: assignment.end_datetime + 10.minutes) + create(:assignment, roster:, end_datetime: assignment.end_datetime + 5.minutes) + create(:assignment, roster:, end_datetime: assignment.end_datetime - 10.minutes) + create :assignment, end_datetime: assignment.end_datetime - 5.minutes end - it { is_expected.not_to include assignment_today } - it { is_expected.to include assignment_tomorrow } + it "returns the predecessor's end datetime" do + expect(call).to eq(predecessor.end_datetime) + end end - end - describe '.send_reminders!' do - subject(:call) { described_class.send_reminders! } + context 'when the assignment has no predecessor' do + let(:roster) { create :roster } + let(:assignment) { build_stubbed(:assignment, roster:) } - let!(:assignment_today) { create :assignment, start_date: Time.zone.today } - let!(:assignment_tomorrow) { create :assignment, start_date: Date.tomorrow } - - before { allow(AssignmentsMailer).to receive(:upcoming_reminder).and_call_original } - - it 'sends reminders about assignments starting tomorrow' do - call - expect(AssignmentsMailer).to have_received(:upcoming_reminder) - .with(assignment_tomorrow.roster, - assignment_tomorrow.effective_start_datetime, any_args) - end - - it 'does not send reminders about assignments starting today' do - call - expect(AssignmentsMailer).not_to have_received(:upcoming_reminder) - .with(assignment_today.roster, - assignment_today.effective_start_datetime, any_args) + it "returns the roster's create datetime" do + expect(call).to eq(roster.created_at) + end end end - describe '#user_in_roster' do - let(:roster) { create :roster } - let(:user_not_in_roster) { create :user } - - let(:assignment) do - build :assignment, roster: roster, user: user_not_in_roster - end - - it 'adds error message if the user is not in the roster' do - expect(assignment).not_to be_valid + describe '.with_start_datetimes' do + subject(:call) { described_class.with_start_datetimes } + + let(:time) { Time.current } + let(:rosters) { create_list :roster, 2 } + let!(:roster_one_assignments) do + [1.minute.after(time), 1.minute.before(time), 3.minutes.before(time)].map do |end_datetime| + create(:assignment, roster: rosters.first, end_datetime:) + end.sort_by(&:end_datetime) + end + let!(:roster_two_assignments) do + [2.minutes.after(time), time, 2.minutes.before(time)].map do |end_datetime| + create(:assignment, roster: rosters.second, end_datetime:) + end.sort_by(&:end_datetime) + end + + it 'returns a relation' do + expect(call).to be_a(ActiveRecord::Relation) + end + + it 'preloads start datetimes at the database level and writes them to attributes' do + expect(call.collect(&:attributes)).to contain_exactly( + a_hash_including('id' => roster_one_assignments.first.id, + 'start_datetime' => nil), + a_hash_including('id' => roster_one_assignments.second.id, + 'start_datetime' => roster_one_assignments.first.end_datetime), + a_hash_including('id' => roster_one_assignments.third.id, + 'start_datetime' => roster_one_assignments.second.end_datetime), + a_hash_including('id' => roster_two_assignments.first.id, + 'start_datetime' => nil), + a_hash_including('id' => roster_two_assignments.second.id, + 'start_datetime' => roster_two_assignments.first.end_datetime), + a_hash_including('id' => roster_two_assignments.third.id, + 'start_datetime' => roster_two_assignments.second.end_datetime) + ) end end end From 394656c74725858d346c24288785d0d910c14aed Mon Sep 17 00:00:00 2001 From: benmelz Date: Thu, 19 Mar 2026 15:45:53 -0400 Subject: [PATCH 03/11] get rosters index working in practice --- app/controllers/rosters_controller.rb | 2 +- app/models/roster.rb | 24 ++++++++++------------- app/views/rosters/_on_call_user.html.haml | 9 ++++----- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/app/controllers/rosters_controller.rb b/app/controllers/rosters_controller.rb index 4632c41c..1eb5573f 100644 --- a/app/controllers/rosters_controller.rb +++ b/app/controllers/rosters_controller.rb @@ -6,7 +6,7 @@ class RostersController < ApplicationController def index authorize! - @rosters = authorized_scope(Roster.all).page(params[:page]) + @rosters = authorized_scope(Roster.includes(:fallback_user, current_assignment: :user)).page(params[:page]) end def show diff --git a/app/models/roster.rb b/app/models/roster.rb index eab1dbb3..1643ec6e 100644 --- a/app/models/roster.rb +++ b/app/models/roster.rb @@ -5,33 +5,29 @@ class Roster < ApplicationRecord extend FriendlyId - has_paper_trail friendly_id :name, use: :slugged + has_paper_trail + + belongs_to :fallback_user, class_name: 'User', optional: true, inverse_of: 'fallback_rosters' has_many :assignments, dependent: :destroy has_many :memberships, dependent: :destroy - has_many :admin_memberships, -> { where(admin: true) }, - class_name: 'Membership', dependent: nil, inverse_of: :roster - has_many :non_admin_memberships, -> { where.not(admin: true) }, - class_name: 'Membership', dependent: nil, inverse_of: :roster + + has_one :current_assignment, + -> { where(arel_table[:end_datetime].gt(Time.current)).order(end_datetime: :asc) }, + class_name: 'Assignment', dependent: nil, inverse_of: :roster + has_many :admin_memberships, -> { where(admin: true) }, class_name: 'Membership', dependent: nil, inverse_of: :roster has_many :users, through: :memberships has_many :admins, through: :admin_memberships, source: :user - has_many :non_admins, through: :non_admin_memberships, source: :user - - belongs_to :fallback_user, class_name: 'User', - optional: true, - inverse_of: 'fallback_rosters' validates :name, presence: true, uniqueness: { case_sensitive: false } validates :switchover, numericality: { in: (0...(24 * 60)), message: :invalid_time } - validates :phone, phone: { allow_blank: true } + validates :phone, presence: true, phone: { allow_blank: true } after_commit :notify_fallback_number_changed, on: :update - def on_call_user - assignments.current.try(:user) || fallback_user - end + def on_call_user = current_assignment&.user || fallback_user def switchover_time switchover.presence && Time.zone.now.midnight.in(switchover.minutes) diff --git a/app/views/rosters/_on_call_user.html.haml b/app/views/rosters/_on_call_user.html.haml index 87767890..683bda47 100644 --- a/app/views/rosters/_on_call_user.html.haml +++ b/app/views/rosters/_on_call_user.html.haml @@ -7,17 +7,16 @@ %i.fa-solid.fa-triangle-exclamation Nobody is currently on call - - if roster.assignments.current.blank? && roster.on_call_user.present? + - if roster.current_assignment&.user.blank? && roster.on_call_user.present? %span.text-primary %i.fa-solid.fa-triangle-exclamation as a fallback - - if roster.assignments.current.present? && roster.fallback_user.blank? + - if roster.on_call_user.present? && roster.fallback_user.blank? %span.text-primary %i.fa-solid.fa-triangle-exclamation with no fallback - -- if roster.assignments.current.present? +- if roster.current_assignment.present? %div %small Switches on - = l roster.assignments.current&.effective_end_datetime, format: :named + = l roster.current_assignment.end_datetime, format: :named From bdb7d352bf9ea1f26820beeafc07f19dec315ed0 Mon Sep 17 00:00:00 2001 From: benmelz Date: Mon, 30 Mar 2026 11:49:34 -0400 Subject: [PATCH 04/11] helpful scopes --- app/models/assignment.rb | 16 ++++++---------- app/models/roster.rb | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/app/models/assignment.rb b/app/models/assignment.rb index add62985..98a67561 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -10,19 +10,15 @@ class Assignment < ApplicationRecord validates :end_datetime, presence: true, uniqueness: { scope: :roster_id } + scope :ending_before, ->(time) { where(arel_table[:end_datetime].lt(time)) } + scope :ending_after, ->(time) { where(arel_table[:end_datetime].gt(time)) } + scope :overlapping, ->(range) { where(start_datetime: nil..range.end, end_datetime: range.begin..nil) } + def start_datetime = super.presence || previous&.end_datetime || roster.created_at - def previous - roster.assignments - .where(self.class.arel_table[:end_datetime].lt(end_datetime)) - .order(end_datetime: :desc).first - end + def previous = roster.assignments.ending_before(end_datetime).order(end_datetime: :desc).first - def next - roster.assignments - .where(self.class.arel_table[:end_datetime].gt(end_datetime)) - .order(end_datetime: :asc).first - end + def next = roster.assignments.ending_after(end_datetime).order(end_datetime: :asc).first class << self def with_start_datetimes diff --git a/app/models/roster.rb b/app/models/roster.rb index 1643ec6e..bae26584 100644 --- a/app/models/roster.rb +++ b/app/models/roster.rb @@ -14,7 +14,7 @@ class Roster < ApplicationRecord has_many :memberships, dependent: :destroy has_one :current_assignment, - -> { where(arel_table[:end_datetime].gt(Time.current)).order(end_datetime: :asc) }, + -> { ending_after(Time.current).order(end_datetime: :asc).order(end_datetime: :asc) }, class_name: 'Assignment', dependent: nil, inverse_of: :roster has_many :admin_memberships, -> { where(admin: true) }, class_name: 'Membership', dependent: nil, inverse_of: :roster From 9aa572ed48f9050c41525062a1715b925a2db0a2 Mon Sep 17 00:00:00 2001 From: Zafnun Hasnat Date: Wed, 15 Apr 2026 13:39:12 -0400 Subject: [PATCH 05/11] Fix rosters controller (#956) --------- Co-authored-by: benmelz --- app/controllers/rosters_controller.rb | 21 +++++++++++++-------- app/models/membership.rb | 2 +- app/views/rosters/show.html.haml | 6 +++--- app/views/rosters/show.json.jbuilder | 14 +++++++++----- spec/requests/rosters_spec.rb | 8 +++++--- 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/app/controllers/rosters_controller.rb b/app/controllers/rosters_controller.rb index 1eb5573f..2d4c1d83 100644 --- a/app/controllers/rosters_controller.rb +++ b/app/controllers/rosters_controller.rb @@ -10,14 +10,9 @@ def index end def show - authorize! @roster, context: { api_key: } respond_to do |format| - format.html do - @your_assignments = @roster.assignments.upcoming.joins(:user).where(user: Current.user).order(start_date: :asc) - end - format.json do - @upcoming = @roster.assignments.upcoming.order(:start_date) - end + format.html { show_html } + format.json { show_json } end end @@ -75,5 +70,15 @@ def roster_params params.expect roster: %i[name phone fallback_user_id switchover_time] end - def api_key = request.headers['Authorization']&.split&.last + def show_html + authorize! @roster + @your_assignments = @roster.assignments.with_start_datetimes + .ending_after(Time.current).where(user: Current.user) + .order(end_datetime: :asc) + end + + def show_json + authorize! @roster, context: { api_key: request.headers['Authorization']&.split&.last } + @upcoming = @roster.assignments.ending_after(Time.current).order(end_datetime: :asc) + end end diff --git a/app/models/membership.rb b/app/models/membership.rb index ab38938a..afb3a36f 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -13,6 +13,6 @@ class Membership < ApplicationRecord private def delete_future_assignments - user.assignments.where(roster:).future.each(&:destroy!) + Assignment.where(roster:, user:).ending_after(Time.current).find_each { |assignment| assignment.update!(user: nil) } end end diff --git a/app/views/rosters/show.html.haml b/app/views/rosters/show.html.haml index 7eb234cf..547f57ac 100644 --- a/app/views/rosters/show.html.haml +++ b/app/views/rosters/show.html.haml @@ -11,12 +11,12 @@ .list-group-item .float-end.text-muted in - = distance_of_time_in_words_to_now(assignment.effective_start_datetime) + = distance_of_time_in_words_to_now(assignment.start_datetime) %div From - %span.text-muted= l assignment.effective_start_datetime, format: :named + %span.text-muted= l assignment.start_datetime, format: :named to - %span.text-muted= l assignment.effective_end_datetime, format: :named + %span.text-muted= l assignment.end_datetime, format: :named .card.mb-3 .card-header .d-flex diff --git a/app/views/rosters/show.json.jbuilder b/app/views/rosters/show.json.jbuilder index 0bf57f9f..c1619581 100644 --- a/app/views/rosters/show.json.jbuilder +++ b/app/views/rosters/show.json.jbuilder @@ -1,16 +1,20 @@ # frozen_string_literal: true +current_user = @roster.on_call_user +current_assignment = @roster.current_assignment +next_assignment = current_assignment&.next + json.extract! @roster, :id, :name, :slug, :phone -if (user = @roster.on_call_user).present? +if current_user.present? json.on_call do - json.extract! user, :last_name, :first_name - json.until @roster.assignments.current&.effective_end_datetime&.iso8601 + json.extract! current_user, :last_name, :first_name + json.until current_assignment&.end_datetime&.iso8601 end else json.on_call nil end -if @upcoming.present? +if next_assignment&.user.present? json.upcoming do - json.extract! @upcoming.first.user, :last_name, :first_name + json.extract! next_assignment.user, :last_name, :first_name end end diff --git a/spec/requests/rosters_spec.rb b/spec/requests/rosters_spec.rb index a3283545..fe810f06 100644 --- a/spec/requests/rosters_spec.rb +++ b/spec/requests/rosters_spec.rb @@ -111,7 +111,7 @@ let(:on_call_user) { create :user, rosters: [roster] } let!(:assignment) do - create(:assignment, roster:, user: on_call_user, start_date: Date.yesterday, end_date: Date.tomorrow) + create(:assignment, roster:, user: on_call_user, end_datetime: 1.day.from_now) end it 'responds with roster data' do @@ -124,7 +124,7 @@ on_call: { last_name: on_call_user.last_name, first_name: on_call_user.first_name, - until: assignment.effective_end_datetime.iso8601 + until: assignment.end_datetime.iso8601 } }.deep_stringify_keys) end @@ -133,10 +133,12 @@ context 'when there is an upcoming assignment' do include_context 'when logged in as a member of the roster' + let(:on_call_user) { create :user, rosters: [roster] } let(:upcoming_user) { create :user, rosters: [roster] } before do - create(:assignment, roster:, user: upcoming_user, start_date: Date.tomorrow, end_date: 2.days.from_now) + create(:assignment, roster:, user: nil, end_datetime: 1.day.from_now) + create(:assignment, roster:, user: upcoming_user, end_datetime: 2.days.from_now) end it 'responds with roster data' do From 732981395d71f12a27a550f34bea317600dcd1ee Mon Sep 17 00:00:00 2001 From: Zafnun Hasnat Date: Wed, 22 Apr 2026 12:13:12 -0400 Subject: [PATCH 06/11] Fix assignment controller index.json (#960) --------- Co-authored-by: benmelz --- app/controllers/assignments_controller.rb | 10 +++-- app/models/assignment.rb | 1 - app/views/assignments/index.json.jbuilder | 7 ++-- spec/requests/assignments_spec.rb | 51 +++++++++++++++-------- 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb index a46f25ba..929dcf61 100644 --- a/app/controllers/assignments_controller.rb +++ b/app/controllers/assignments_controller.rb @@ -9,9 +9,7 @@ class AssignmentsController < ApplicationController def index authorize! respond_to do |format| - format.json do - @assignments = roster.assignments.between(Date.parse(params[:start_date]), Date.parse(params[:end_date])) - end + format.json { index_json } format.csv do render csv: roster.assignment_csv, filename: roster.name end @@ -74,4 +72,10 @@ def initialize_assignment def assignment_params params.expect assignment: %i[start_date end_date user_id] end + + def index_json + @assignments = roster.assignments.with_start_datetimes.preload(:user) + .where(start_datetime: nil..Date.parse(params[:end_date]).at_end_of_day, + end_datetime: Date.parse(params[:start_date]).at_beginning_of_day..nil) + end end diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 98a67561..0dad89bd 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -12,7 +12,6 @@ class Assignment < ApplicationRecord scope :ending_before, ->(time) { where(arel_table[:end_datetime].lt(time)) } scope :ending_after, ->(time) { where(arel_table[:end_datetime].gt(time)) } - scope :overlapping, ->(range) { where(start_datetime: nil..range.end, end_datetime: range.begin..nil) } def start_datetime = super.presence || previous&.end_datetime || roster.created_at diff --git a/app/views/assignments/index.json.jbuilder b/app/views/assignments/index.json.jbuilder index 42aead65..0038c29c 100644 --- a/app/views/assignments/index.json.jbuilder +++ b/app/views/assignments/index.json.jbuilder @@ -2,10 +2,9 @@ json.array! @assignments do |assignment| json.id "assignment-#{assignment.id}" - json.title assignment.user.last_name + json.title assignment.user&.last_name || 'Open' json.url edit_assignment_path(assignment) - json.allDay true - json.start assignment.start_date.to_fs(:iso8601) - json.end 1.day.after(assignment.end_date).to_fs(:iso8601) + json.start assignment.start_datetime.to_fs(:iso8601) + json.end assignment.end_datetime.to_fs(:iso8601) json.color("var(--#{assignment.user == Current.user ? 'bs-primary' : 'bs-secondary'})") end diff --git a/spec/requests/assignments_spec.rb b/spec/requests/assignments_spec.rb index 789b69af..361edf97 100644 --- a/spec/requests/assignments_spec.rb +++ b/spec/requests/assignments_spec.rb @@ -15,7 +15,7 @@ describe 'GET /rosters/:roster_id/assignments.json' do subject(:call) do - get "/rosters/#{roster.slug}/assignments.json", params: { start_date: 1.month.ago, end_date: 1.month.from_now } + get "/rosters/#{roster.slug}/assignments.json", params: { start_date: Date.current, end_date: Date.tomorrow } end let(:roster) { create :roster } @@ -32,14 +32,19 @@ context 'when logged in as a member of the roster' do include_context 'when logged in as a member of the roster' - let!(:own_assignment) do - create :assignment, roster:, user: current_user, start_date: Date.current, end_date: Date.tomorrow + let!(:past_assignment) { create :assignment, roster:, end_datetime: Date.yesterday.at_middle_of_day } + let!(:open_assignment) { create :assignment, roster:, end_datetime: Date.current.at_middle_of_day } + let!(:taken_assignment) do + create :assignment, roster:, + user: create(:user, rosters: [roster]), + end_datetime: Date.tomorrow.at_middle_of_day end - let!(:other_assignment) do - create :assignment, roster:, user: create(:user, rosters: [roster]), - start_date: 2.days.from_now, end_date: 4.days.from_now + let!(:own_assignment) do + create :assignment, roster:, user: current_user, end_datetime: 2.days.from_now.at_middle_of_day end + before { create :assignment, roster:, end_datetime: 3.days.from_now.at_middle_of_day } + it 'responds successfully' do call expect(response).to be_successful @@ -48,20 +53,30 @@ it 'responds with calendar data' do call expect(response.parsed_body).to contain_exactly( - { 'id' => "assignment-#{own_assignment.id}", + a_hash_including( + 'id' => "assignment-#{open_assignment.id}", + 'title' => 'Open', + 'url' => edit_assignment_path(open_assignment), + 'start' => past_assignment.end_datetime.iso8601, + 'end' => open_assignment.end_datetime.iso8601, + 'color' => 'var(--bs-secondary)' + ), + a_hash_including( + 'id' => "assignment-#{taken_assignment.id}", + 'title' => taken_assignment.user.last_name, + 'url' => edit_assignment_path(taken_assignment), + 'start' => open_assignment.end_datetime.iso8601, + 'end' => taken_assignment.end_datetime.iso8601, + 'color' => 'var(--bs-secondary)' + ), + a_hash_including( + 'id' => "assignment-#{own_assignment.id}", 'title' => own_assignment.user.last_name, 'url' => edit_assignment_path(own_assignment), - 'allDay' => true, - 'start' => own_assignment.start_date.iso8601, - 'end' => (own_assignment.end_date + 1).iso8601, - 'color' => 'var(--bs-primary)' }, - { 'id' => "assignment-#{other_assignment.id}", - 'title' => other_assignment.user.last_name, - 'url' => edit_assignment_path(other_assignment), - 'allDay' => true, - 'start' => other_assignment.start_date.iso8601, - 'end' => (other_assignment.end_date + 1).iso8601, - 'color' => 'var(--bs-secondary)' } + 'start' => taken_assignment.end_datetime.iso8601, + 'end' => own_assignment.end_datetime.iso8601, + 'color' => 'var(--bs-primary)' + ) ) end end From 3595979dccc1ac78cde2fe3ce595d0d98a734fd4 Mon Sep 17 00:00:00 2001 From: Zafnun Hasnat Date: Thu, 30 Apr 2026 11:29:08 -0400 Subject: [PATCH 07/11] Fix assignment forms (#988) --------- Co-authored-by: benmelz --- app/controllers/assignments_controller.rb | 6 +----- .../controllers/assignment_calendar_controller.js | 12 +----------- app/policies/assignment_policy.rb | 2 +- app/views/assignments/_fields.html.haml | 15 +++++---------- app/views/rosters/show.html.haml | 5 ++--- spec/requests/assignments_spec.rb | 8 ++++---- 6 files changed, 14 insertions(+), 34 deletions(-) diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb index 929dcf61..321e637b 100644 --- a/app/controllers/assignments_controller.rb +++ b/app/controllers/assignments_controller.rb @@ -63,14 +63,10 @@ def find_assignment def initialize_assignment @assignment = roster.assignments.new - return if params[:date].blank? - - @assignment.start_date = params[:date].to_date - @assignment.end_date = @assignment.start_date + 6.days end def assignment_params - params.expect assignment: %i[start_date end_date user_id] + params.expect assignment: %i[end_datetime user_id] end def index_json diff --git a/app/javascript/controllers/assignment_calendar_controller.js b/app/javascript/controllers/assignment_calendar_controller.js index 48d61fb0..52aaf769 100644 --- a/app/javascript/controllers/assignment_calendar_controller.js +++ b/app/javascript/controllers/assignment_calendar_controller.js @@ -4,10 +4,7 @@ import dayGridPlugin from '@fullcalendar/daygrid'; import {Controller} from '@hotwired/stimulus'; export default class extends Controller { - static values = { - eventsUrl: String, - newAssignmentUrl: String, - }; + static values = {eventsUrl: String}; connect() { const calendar = new Calendar(this.element, { @@ -51,12 +48,5 @@ export default class extends Controller { }); calendar.render(); - - this.element.addEventListener('click', (e) => { - const dayElement = e.target.closest('td.day-empty'); - if (dayElement) { - window.location = `${this.newAssignmentUrlValue}?date=` + dayElement.dataset.date; - } - }); } } diff --git a/app/policies/assignment_policy.rb b/app/policies/assignment_policy.rb index 5736bcc4..da41da95 100644 --- a/app/policies/assignment_policy.rb +++ b/app/policies/assignment_policy.rb @@ -23,5 +23,5 @@ def update? def not_assigning_someone_else? = record.changes.slice('user_id').blank? || record.user == user - def not_changing_dates? = record.changes.slice('start_date', 'end_date').blank? + def not_changing_dates? = record.changes.slice('end_datetime').blank? end diff --git a/app/views/assignments/_fields.html.haml b/app/views/assignments/_fields.html.haml index 1cdd086f..e4458d69 100644 --- a/app/views/assignments/_fields.html.haml +++ b/app/views/assignments/_fields.html.haml @@ -3,17 +3,12 @@ - if allowed_to?(:manage?, fields.object) = fields.collection_select :user_id, fields.object.roster.users.order(:first_name, :last_name), :id, :full_name, - {}, required: true, class: 'form-select', 'data-controller': 'tom-select' + { include_blank: true }, class: 'form-select', 'data-controller': 'tom-select' - else = fields.text_field :user_id, value: Current.user.full_name, disabled: true, class: 'form-control' = fields.hidden_field :user_id, value: Current.user.id .mb-3 - = fields.label :start_date, class: 'form-label' - = fields.date_field :start_date, required: true, - disabled: fields.object.persisted? && !allowed_to?(:manage?, fields.object), - class: 'form-control' -.mb-3 - = fields.label :end_date, class: 'form-label' - = fields.date_field :end_date, required: true, - disabled: fields.object.persisted? && !allowed_to?(:manage?, fields.object), - class: 'form-control' + = fields.label :end_datetime, class: 'form-label' + = fields.datetime_field :end_datetime, required: true, + disabled: fields.object.persisted? && !allowed_to?(:manage?, fields.object), + class: 'form-control' diff --git a/app/views/rosters/show.html.haml b/app/views/rosters/show.html.haml index 547f57ac..ab273dbc 100644 --- a/app/views/rosters/show.html.haml +++ b/app/views/rosters/show.html.haml @@ -39,7 +39,6 @@ - if allowed_to?(:prompt, WeekdayAssigner.new(roster_id: @roster.id)) %li= link_to 'Weekdays', roster_assign_weekdays_path(@roster), class: 'dropdown-item' .card-body - .mb-3{ 'data-controller': 'assignment-calendar', - 'data-assignment-calendar-events-url-value': roster_assignments_path(@roster, format: :json), - 'data-assignment-calendar-new-assignment-url-value': new_roster_assignment_path(@roster) } + .mb-3{ data: { controller: 'assignment-calendar', + 'assignment-calendar-events-url-value': roster_assignments_path(@roster, format: :json) } } = render partial: 'feeds/url', locals: { roster: @roster } diff --git a/spec/requests/assignments_spec.rb b/spec/requests/assignments_spec.rb index 361edf97..344499a2 100644 --- a/spec/requests/assignments_spec.rb +++ b/spec/requests/assignments_spec.rb @@ -5,12 +5,12 @@ RSpec.describe 'Assignments' do shared_context 'with valid attributes' do let(:attributes) do - { user_id: create(:user, rosters: [roster]).id, start_date: Date.current, end_date: Date.tomorrow } + { user_id: create(:user, rosters: [roster]).id, end_datetime: Time.zone.tomorrow.middle_of_day } end end shared_context 'with invalid attributes' do - let(:attributes) { { user_id: nil, start_date: nil, end_date: nil } } + let(:attributes) { { user_id: nil, end_datetime: nil } } end describe 'GET /rosters/:roster_id/assignments.json' do @@ -197,7 +197,7 @@ context 'when logged in as a member of the roster assigning themselves' do include_context 'when logged in as a member of the roster' - let(:attributes) { { user_id: current_user.id, start_date: Date.current, end_date: Date.tomorrow } } + let(:attributes) { { user_id: current_user.id, end_datetime: Time.zone.tomorrow.middle_of_day } } it 'redirects to the roster' do submit @@ -304,7 +304,7 @@ context 'when logged in as a member of the roster changing dates' do include_context 'when logged in as a member of the roster' - let(:attributes) { { start_date: Date.current, end_date: Date.tomorrow } } + let(:attributes) { { end_datetime: Time.zone.tomorrow.middle_of_day } } it 'responds with a forbidden status' do submit From 413447df83a76cbdd8e80bb6e454a8784a787478 Mon Sep 17 00:00:00 2001 From: Zafnun Hasnat Date: Thu, 30 Apr 2026 11:57:34 -0400 Subject: [PATCH 08/11] Fix assignments csv (#994) --------- Co-authored-by: benmelz --- app/controllers/assignments_controller.rb | 8 ++++--- app/models/assignment.rb | 18 ++++++++++++++ app/models/roster.rb | 20 ---------------- spec/requests/assignments_spec.rb | 29 +++++++++++++---------- 4 files changed, 40 insertions(+), 35 deletions(-) diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb index 321e637b..6dc20b05 100644 --- a/app/controllers/assignments_controller.rb +++ b/app/controllers/assignments_controller.rb @@ -10,9 +10,7 @@ def index authorize! respond_to do |format| format.json { index_json } - format.csv do - render csv: roster.assignment_csv, filename: roster.name - end + format.csv { index_csv } end end @@ -74,4 +72,8 @@ def index_json .where(start_datetime: nil..Date.parse(params[:end_date]).at_end_of_day, end_datetime: Date.parse(params[:start_date]).at_beginning_of_day..nil) end + + def index_csv + render csv: roster.assignments.with_start_datetimes.order(end_datetime: :asc).to_csv, filename: roster.name + end end diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 0dad89bd..b0b25f4a 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -24,6 +24,24 @@ def with_start_datetimes from arel_table.project(arel_table[Arel.star], start_datetime_node).as(arel_table.name) end + def to_csv # rubocop:disable Metrics + CSV.generate headers: %i[roster email first_name last_name start end created_at updated_at], + write_headers: true do |csv| + all.each do |assignment| # rubocop:disable Rails/FindEach + csv << { + roster: assignment.roster.name, + email: assignment.user.email, + first_name: assignment.user.first_name, + last_name: assignment.user.last_name, + start: assignment.start_datetime.iso8601, + end: assignment.end_datetime.iso8601, + created_at: assignment.created_at.iso8601, + updated_at: assignment.updated_at.iso8601 + } + end + end + end + private def start_datetime_node diff --git a/app/models/roster.rb b/app/models/roster.rb index bae26584..068d7158 100644 --- a/app/models/roster.rb +++ b/app/models/roster.rb @@ -48,15 +48,6 @@ def uncovered_dates_between(start_date, end_date) end end - def assignment_csv - CSV.generate headers: %w[roster email first_name last_name start_date end_date created_at updated_at], - write_headers: true do |csv| - assignments.sort_by(&:start_date).each do |assignment| - csv << assignment_csv_row(assignment) - end - end - end - # Returns the day AFTER the last assignment ends. # If there is no last assignment, returns the upcoming Friday. def next_rotation_start_date @@ -76,15 +67,4 @@ def notify_fallback_number_changed RosterMailer.with(roster: self).fallback_number_changed.deliver_later end - - def assignment_csv_row(assignment) - { 'roster' => name, - 'email' => assignment.user.email, - 'first_name' => assignment.user.first_name, - 'last_name' => assignment.user.last_name, - 'start_date' => assignment.start_date.to_fs(:db), - 'end_date' => assignment.end_date.to_fs(:db), - 'created_at' => assignment.created_at.to_fs(:db), - 'updated_at' => assignment.updated_at.to_fs(:db) } - end end diff --git a/spec/requests/assignments_spec.rb b/spec/requests/assignments_spec.rb index 344499a2..aa23d6c1 100644 --- a/spec/requests/assignments_spec.rb +++ b/spec/requests/assignments_spec.rb @@ -99,12 +99,13 @@ context 'when logged in as a member of the roster' do include_context 'when logged in as a member of the roster' + let(:roster) { create :roster, created_at: 2.days.ago.middle_of_day } let(:users) { create_list :user, 2, rosters: [roster] } - let!(:current_assignment) do - create :assignment, roster:, user: users[0], start_date: Date.current, end_date: 1.day.from_now - end - let!(:past_assignment) do - create :assignment, roster:, user: users[1], start_date: 2.days.ago, end_date: 1.day.ago + let!(:assignments) do + [ + create(:assignment, roster:, user: users.first, end_datetime: Date.current.middle_of_day), + create(:assignment, roster:, user: users.second, end_datetime: Date.yesterday.middle_of_day) + ] end it 'responds successfully' do @@ -114,14 +115,18 @@ it 'responds with assignment data for the given roster' do call - row1 = [roster.name, users[1].email, users[1].first_name, users[1].last_name, - past_assignment.start_date.to_fs(:db), past_assignment.end_date.to_fs(:db), - past_assignment.created_at.to_fs(:db), past_assignment.updated_at.to_fs(:db)].join ',' - row2 = [roster.name, users[0].email, users[0].first_name, users[0].last_name, - current_assignment.start_date.to_fs(:db), current_assignment.end_date.to_fs(:db), - current_assignment.created_at.to_fs(:db), current_assignment.updated_at.to_fs(:db)].join ',' + row1 = [roster.name, users.second.email, users.second.first_name, users.second.last_name, + roster.created_at.iso8601, + assignments.second.end_datetime.iso8601, + assignments.second.created_at.iso8601, + assignments.second.updated_at.iso8601].join(',') + row2 = [roster.name, users.first.email, users.first.first_name, users.first.last_name, + assignments.second.end_datetime.iso8601, + assignments.first.end_datetime.iso8601, + assignments.first.created_at.iso8601, + assignments.first.updated_at.iso8601].join(',') expect(response.body).to eq(<<~CSV) - roster,email,first_name,last_name,start_date,end_date,created_at,updated_at + roster,email,first_name,last_name,start,end,created_at,updated_at #{row1} #{row2} CSV From 76b20e9b98a948995f32cc59f487f23c7758d95c Mon Sep 17 00:00:00 2001 From: benmelz Date: Mon, 4 May 2026 10:55:01 -0400 Subject: [PATCH 09/11] Consider roster created_at values for assignment start datetimes (#1001) --- app/models/assignment.rb | 14 +++++++++++--- spec/factories/rosters.rb | 1 + spec/models/assignment_spec.rb | 11 +++++++---- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/app/models/assignment.rb b/app/models/assignment.rb index b0b25f4a..95204b9f 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -8,7 +8,11 @@ class Assignment < ApplicationRecord belongs_to :roster belongs_to :user, optional: true - validates :end_datetime, presence: true, uniqueness: { scope: :roster_id } + validates :end_datetime, comparison: { greater_than: ->(assignment) { assignment.roster.created_at }, + if: ->(assignment) { assignment.roster.present? }, + allow_nil: true }, + presence: true, + uniqueness: { scope: :roster_id } scope :ending_before, ->(time) { where(arel_table[:end_datetime].lt(time)) } scope :ending_after, ->(time) { where(arel_table[:end_datetime].gt(time)) } @@ -21,7 +25,9 @@ def next = roster.assignments.ending_after(end_datetime).order(end_datetime: :as class << self def with_start_datetimes - from arel_table.project(arel_table[Arel.star], start_datetime_node).as(arel_table.name) + from arel_table.join(Roster.arel_table).on(arel_table[:roster_id].eq(Roster.arel_table[:id])) + .project(arel_table[Arel.star], start_datetime_node) + .as(arel_table.name) end def to_csv # rubocop:disable Metrics @@ -46,7 +52,9 @@ def to_csv # rubocop:disable Metrics def start_datetime_node Arel::Nodes::Over.new( - Arel::Nodes::NamedFunction.new('LAG', [arel_table[:end_datetime]]), + Arel::Nodes::NamedFunction.new('LAG', [arel_table[:end_datetime], + Arel::Nodes::SqlLiteral.new('1'), + Roster.arel_table[:created_at]]), Arel::Nodes::Window.new.partition(arel_table[:roster_id]).order(arel_table[:end_datetime]) ).as(arel_table[:start_datetime].name) end diff --git a/spec/factories/rosters.rb b/spec/factories/rosters.rb index 1bc71909..1912b1c5 100644 --- a/spec/factories/rosters.rb +++ b/spec/factories/rosters.rb @@ -5,5 +5,6 @@ sequence(:name) { |n| "Name #{n}" } sequence(:phone) { |n| format('+1413545%04d', n) } switchover { (16 * 60) + 30 } + created_at { 1.week.ago } end end diff --git a/spec/models/assignment_spec.rb b/spec/models/assignment_spec.rb index c116b25c..4656ab80 100644 --- a/spec/models/assignment_spec.rb +++ b/spec/models/assignment_spec.rb @@ -11,8 +11,9 @@ end describe 'validations' do - subject { build :assignment } + subject(:assignment) { create :assignment } + it { is_expected.not_to allow_value(assignment.roster.created_at).for(:end_datetime) } it { is_expected.to validate_presence_of(:end_datetime) } it { is_expected.to validate_uniqueness_of(:end_datetime).scoped_to(:roster_id) } end @@ -114,7 +115,9 @@ subject(:call) { described_class.with_start_datetimes } let(:time) { Time.current } - let(:rosters) { create_list :roster, 2 } + let(:rosters) do + [create(:roster, created_at: 5.minutes.before(time)), create(:roster, created_at: 4.minutes.before(time))] + end let!(:roster_one_assignments) do [1.minute.after(time), 1.minute.before(time), 3.minutes.before(time)].map do |end_datetime| create(:assignment, roster: rosters.first, end_datetime:) @@ -133,13 +136,13 @@ it 'preloads start datetimes at the database level and writes them to attributes' do expect(call.collect(&:attributes)).to contain_exactly( a_hash_including('id' => roster_one_assignments.first.id, - 'start_datetime' => nil), + 'start_datetime' => rosters.first.created_at), a_hash_including('id' => roster_one_assignments.second.id, 'start_datetime' => roster_one_assignments.first.end_datetime), a_hash_including('id' => roster_one_assignments.third.id, 'start_datetime' => roster_one_assignments.second.end_datetime), a_hash_including('id' => roster_two_assignments.first.id, - 'start_datetime' => nil), + 'start_datetime' => rosters.second.created_at), a_hash_including('id' => roster_two_assignments.second.id, 'start_datetime' => roster_two_assignments.first.end_datetime), a_hash_including('id' => roster_two_assignments.third.id, From ca0fa5247030883ea499dfd47f38fe6f3ba325cb Mon Sep 17 00:00:00 2001 From: Zafnun Hasnat Date: Mon, 4 May 2026 13:32:11 -0400 Subject: [PATCH 10/11] Fix versions and twilio spec (#1002) --- spec/requests/twilio_spec.rb | 4 ++-- spec/requests/versions_spec.rb | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/spec/requests/twilio_spec.rb b/spec/requests/twilio_spec.rb index e27eb528..379fe776 100644 --- a/spec/requests/twilio_spec.rb +++ b/spec/requests/twilio_spec.rb @@ -3,10 +3,10 @@ require 'rails_helper' RSpec.describe 'Twilio' do - let(:roster) { create :roster } + let(:roster) { create :roster, created_at: 3.days.ago } let(:user) { create :user, rosters: [roster], phone: '(413) 545-0056' } - before { create :assignment, start_date: Date.yesterday, end_date: Date.tomorrow, roster:, user: } + before { create :assignment, end_datetime: Date.tomorrow.beginning_of_day, roster:, user: } describe 'GET /rosters/:roster_id/twilio/call.xml' do let(:call) { get "/rosters/#{roster.slug}/twilio/call", headers: { 'ACCEPT' => 'application/xml' } } diff --git a/spec/requests/versions_spec.rb b/spec/requests/versions_spec.rb index 40ef078d..d1ec3627 100644 --- a/spec/requests/versions_spec.rb +++ b/spec/requests/versions_spec.rb @@ -3,6 +3,8 @@ require 'rails_helper' RSpec.describe 'Versions' do + include ActiveSupport::Testing::TimeHelpers + describe 'POST /versions/:id/undo', :versioning do subject(:submit) { post "/versions/#{version.id}/undo", headers: } @@ -50,14 +52,19 @@ context 'when logged in as the author of an update' do let(:version) do Current.set(user: current_user) do - assignment.tap { |assignment| assignment.update!(start_date: 2.days.ago) }.versions.last + assignment.tap { |assignment| assignment.update!(end_datetime: Time.current) }.versions.last end end - let!(:assignment) { create :assignment, start_date: 1.day.ago } + let(:assignment) { create :assignment, end_datetime: 1.day.ago } + + before do + freeze_time + assignment + end it 'reverts the record' do submit - expect(assignment.reload).to have_attributes(start_date: 1.day.ago.to_date) + expect(assignment.reload).to have_attributes(end_datetime: 1.day.ago) end end From 6c9d0d05eb21620599f4c18f31da51c2012408fd Mon Sep 17 00:00:00 2001 From: Zafnun Hasnat Date: Wed, 6 May 2026 12:05:30 -0400 Subject: [PATCH 11/11] Remove week and weekday assigners (#1010) Co-authored-by: benmelz --- app/controllers/week_assigners_controller.rb | 37 -------- .../weekday_assigners_controller.rb | 33 ------- app/models/week_assigner.rb | 70 -------------- app/models/weekday_assigner.rb | 73 --------------- app/policies/week_assigner_policy.rb | 5 - app/policies/weekday_assigner_policy.rb | 5 - app/views/rosters/show.html.haml | 4 - app/views/week_assigners/prompt.html.haml | 29 ------ app/views/weekday_assigners/prompt.html.haml | 31 ------- config/routes.rb | 6 -- spec/requests/week_assigners_spec.rb | 91 ------------------ spec/requests/weekday_assigners_spec.rb | 93 ------------------- 12 files changed, 477 deletions(-) delete mode 100644 app/controllers/week_assigners_controller.rb delete mode 100644 app/controllers/weekday_assigners_controller.rb delete mode 100644 app/models/week_assigner.rb delete mode 100644 app/models/weekday_assigner.rb delete mode 100644 app/policies/week_assigner_policy.rb delete mode 100644 app/policies/weekday_assigner_policy.rb delete mode 100644 app/views/week_assigners/prompt.html.haml delete mode 100644 app/views/weekday_assigners/prompt.html.haml delete mode 100644 spec/requests/week_assigners_spec.rb delete mode 100644 spec/requests/weekday_assigners_spec.rb diff --git a/app/controllers/week_assigners_controller.rb b/app/controllers/week_assigners_controller.rb deleted file mode 100644 index eae1cdee..00000000 --- a/app/controllers/week_assigners_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -class WeekAssignersController < ApplicationController - include Rosterable - - before_action :initialize_week_assigner - - def prompt - authorize! @assigner - end - - def perform - @assigner.assign_attributes(week_assigner_params) - authorize! @assigner - if @assigner.perform - flash_success_for(Assignment.model_name.human.downcase.pluralize, :create) - redirect_to roster_path(@assigner.roster, date: @assigner.start_date) - else - flash_errors_now_for(@assigner) - render :prompt, status: :unprocessable_content - end - end - - private - - def initialize_week_assigner - default_start = roster.next_rotation_start_date - @assigner = WeekAssigner.new(roster_id: roster.id, - start_date: default_start, - end_date: default_start + 3.months, - user_ids: roster.users.pluck(:id)) - end - - def week_assigner_params - params.expect week_assigner: [:starting_user_id, :start_date, :end_date, { user_ids: [] }] - end -end diff --git a/app/controllers/weekday_assigners_controller.rb b/app/controllers/weekday_assigners_controller.rb deleted file mode 100644 index 2fe45047..00000000 --- a/app/controllers/weekday_assigners_controller.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -class WeekdayAssignersController < ApplicationController - include Rosterable - - before_action :initialize_weekday_assigner - - def prompt - authorize! @assigner - end - - def perform - @assigner.assign_attributes(weekday_assigner_params) - authorize! @assigner - if @assigner.perform - flash_success_for(Assignment.model_name.human.downcase.pluralize, :create) - redirect_to roster_path(@assigner.roster, date: @assigner.start_date) - else - flash_errors_now_for(@assigner) - render :prompt, status: :unprocessable_content - end - end - - private - - def initialize_weekday_assigner - @assigner = WeekdayAssigner.new(roster_id: roster.id) - end - - def weekday_assigner_params - params.expect weekday_assigner: %i[user_id start_date end_date start_weekday end_weekday] - end -end diff --git a/app/models/week_assigner.rb b/app/models/week_assigner.rb deleted file mode 100644 index 7d5355bf..00000000 --- a/app/models/week_assigner.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -class WeekAssigner - include ActiveModel::Model - include ActiveModel::Attributes - - attribute :roster_id, :integer - attribute :user_ids - attribute :starting_user_id, :integer - attribute :start_date, :date - attribute :end_date, :date - - validates :roster, presence: true - validates :user_ids, presence: true - validates :starting_user_id, presence: true - validates :start_date, presence: true - validates :end_date, presence: true, comparison: { greater_than_or_equal_to: :start_date, - if: -> { start_date.present? && end_date.present? }, - message: :must_not_be_before_start } - - validate :includes_start_user - - def perform - perform! - true - rescue ActiveModel::ValidationError, ActiveRecord::RecordInvalid - false - end - - def user_ids=(value) - super(value&.map(&:to_i)) - end - - def roster - return @roster if defined?(@roster) - - @roster = Roster.find_by(id: roster_id) - end - - private - - def includes_start_user - return if user_ids.blank? || starting_user_id.blank? - return if user_ids.include? starting_user_id - - errors.add :starting_user_id, message: :starting_user_must_be_included - end - - def perform! - validate! - [].tap do |assignments| - ActiveRecord::Base.transaction { create_assignments_and_save!(assignments) } - end - rescue ActiveRecord::RecordInvalid => e - errors.merge! e.record.errors - raise e - end - - def create_assignments_and_save!(output) - rotation = user_ids.rotate user_ids.index(starting_user_id) - (start_date..end_date).each_slice(7).with_index do |week, i| - output << Assignment.create!( - roster: @roster, - start_date: week.first, - end_date: week.last, - user_id: rotation[i % rotation.size] - ) - end - end -end diff --git a/app/models/weekday_assigner.rb b/app/models/weekday_assigner.rb deleted file mode 100644 index 034ab3bd..00000000 --- a/app/models/weekday_assigner.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -class WeekdayAssigner - include ActiveModel::Model - include ActiveModel::Attributes - - attribute :roster_id, :integer - attribute :user_id, :integer - attribute :start_date, :date - attribute :end_date, :date - attribute :start_weekday, :integer - attribute :end_weekday, :integer - - validates :roster, presence: true - validates :user, presence: true - validates :start_date, presence: true - validates :end_date, presence: true, - comparison: { greater_than_or_equal_to: :start_date, - if: -> { start_date.present? && end_date.present? }, - message: :must_not_be_before_start } - validates :start_weekday, numericality: { in: 0...7, message: :invalid_weekday } - validates :end_weekday, numericality: { in: 0...7, message: :invalid_weekday } - - def perform - perform! - true - rescue ActiveModel::ValidationError, ActiveRecord::RecordInvalid - false - end - - def roster - return @roster if defined?(@roster) - - @roster = Roster.find_by(id: roster_id) - end - - private - - def user - return @user if defined?(@user) - - @user = User.find_by(id: user_id) - end - - def perform! - validate! - ActiveRecord::Base.transaction do - date_ranges.each do |range| - roster.assignments.create! user:, start_date: range.begin, end_date: range.end - end - end - rescue ActiveRecord::RecordInvalid => e - errors.merge! e.record.errors - raise e - end - - def date_ranges - Enumerator.new do |enum| - weeks.each do |sunday| - end_weekday_adjusted = end_weekday < start_weekday ? end_weekday + 7 : end_weekday - range_start = [start_date, sunday + start_weekday].max - range_end = [end_date, sunday + end_weekday_adjusted].min - next unless range_start <= range_end - - enum.yield range_start..range_end - end - end - end - - def weeks - (start_date.beginning_of_week(:sunday)..end_date.beginning_of_week(:sunday)).step(7) - end -end diff --git a/app/policies/week_assigner_policy.rb b/app/policies/week_assigner_policy.rb deleted file mode 100644 index 75478e2a..00000000 --- a/app/policies/week_assigner_policy.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class WeekAssignerPolicy < ApplicationPolicy - def manage? = allowed_to?(:manage?, record.roster) -end diff --git a/app/policies/weekday_assigner_policy.rb b/app/policies/weekday_assigner_policy.rb deleted file mode 100644 index 8da289ba..00000000 --- a/app/policies/weekday_assigner_policy.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class WeekdayAssignerPolicy < ApplicationPolicy - def manage? = allowed_to?(:manage?, record.roster) -end diff --git a/app/views/rosters/show.html.haml b/app/views/rosters/show.html.haml index ab273dbc..17ed10c5 100644 --- a/app/views/rosters/show.html.haml +++ b/app/views/rosters/show.html.haml @@ -34,10 +34,6 @@ Assign %ul.dropdown-menu %li= link_to 'Single', new_roster_assignment_path(@roster), class: 'dropdown-item' - - if allowed_to?(:prompt?, WeekAssigner.new(roster_id: @roster.id)) - %li= link_to 'Weeks', roster_assign_weeks_path(@roster), class: 'dropdown-item' - - if allowed_to?(:prompt, WeekdayAssigner.new(roster_id: @roster.id)) - %li= link_to 'Weekdays', roster_assign_weekdays_path(@roster), class: 'dropdown-item' .card-body .mb-3{ data: { controller: 'assignment-calendar', 'assignment-calendar-events-url-value': roster_assignments_path(@roster, format: :json) } } diff --git a/app/views/week_assigners/prompt.html.haml b/app/views/week_assigners/prompt.html.haml deleted file mode 100644 index e8d5239b..00000000 --- a/app/views/week_assigners/prompt.html.haml +++ /dev/null @@ -1,29 +0,0 @@ -= render layout: 'layouts/roster', locals: { roster: @assigner.roster, nav_item: 'assignments' } do - .card - .card-header - Assign weeks - .card-body - - user_options = @assigner.roster.users.order(:first_name, :last_name) - = form_with model: @assigner, url: roster_assign_weeks_path(@assigner.roster) do |f| - .mb-3 - = f.label :start_date, class: 'form-label' - = f.date_field :start_date, required: true, class: 'form-control' - .mb-3 - = f.label :end_date, class: 'form-label' - = f.date_field :end_date, required: true, class: 'form-control' - .mb-3 - = f.label :starting_user_id, class: 'form-label' - = f.collection_select :starting_user_id, user_options, :id, :full_name, - {}, class: 'form-select', 'data-controller': 'tom-select' - .mb-3 - = field_set_tag do - %legend.form-label.fs-6 Users - .list-group - = f.collection_check_boxes :user_ids, user_options, :id, :full_name, { include_hidden: false } do |c| - = c.label class: 'list-group-item' do - = c.check_box class: 'form-check-input me-2' - = c.text - .d-grid - = f.button type: :submit, class: 'btn btn-outline-primary' do - %i.fa-solid.fa-check - Assign diff --git a/app/views/weekday_assigners/prompt.html.haml b/app/views/weekday_assigners/prompt.html.haml deleted file mode 100644 index 54d00929..00000000 --- a/app/views/weekday_assigners/prompt.html.haml +++ /dev/null @@ -1,31 +0,0 @@ -= render layout: 'layouts/roster', locals: { roster: @assigner.roster, nav_item: 'assignments' } do - .card - .card-header - Assign weekdays - .card-body - = form_with model: @assigner, url: roster_assign_weekdays_path(@assigner.roster) do |f| - .mb-3 - = f.label :user_id, class: 'form-label' - = f.collection_select :user_id, - @assigner.roster.users.order(:first_name, :last_name), :id, :full_name, - {}, required: true, class: 'form-select', 'data-controller': 'tom-select' - .row.g-3 - .mb-3.col-md - = f.label :start_date, class: 'form-label' - = f.date_field :start_date, required: true, class: 'form-control' - .mb-3.col-md - = f.label :end_date, class: 'form-label' - = f.date_field :end_date, required: true, class: 'form-control' - .row.g-3 - .mb-3.col-md - = f.label :start_weekday, class: 'form-label' - = f.collection_select :start_weekday, Date::DAYNAMES, ->(day) { Date::DAYNAMES.index(day) }, :itself, - {}, required: true, class: 'form-select' - .mb-3.col-md - = f.label :end_weekday, class: 'form-label' - = f.collection_select :end_weekday, Date::DAYNAMES, ->(day) { Date::DAYNAMES.index(day) }, :itself, - {}, required: true, class: 'form-select' - .d-grid - = f.button type: :submit, class: 'btn btn-outline-primary' do - %i.fa-solid.fa-check - Assign diff --git a/config/routes.rb b/config/routes.rb index 54b9c9a7..e32fb128 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,12 +13,6 @@ resources :rosters do resources :assignments, only: %i[index new edit create update destroy], shallow: true - get :assign_weeks, to: 'week_assigners#prompt' - post :assign_weeks, to: 'week_assigners#perform' - - get :assign_weekdays, to: 'weekday_assigners#prompt' - post :assign_weekdays, to: 'weekday_assigners#perform' - resources :memberships, only: %i[index create destroy update], shallow: true get 'twilio/call', to: 'twilio#call', as: :twilio_call diff --git a/spec/requests/week_assigners_spec.rb b/spec/requests/week_assigners_spec.rb deleted file mode 100644 index 05057172..00000000 --- a/spec/requests/week_assigners_spec.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe 'Week Assigners' do - describe 'GET /rosters/:roster_id/assign_weeks' do - subject(:call) { get "/rosters/#{roster.slug}/assign_weeks" } - - let(:roster) { create :roster } - - context 'when logged in as a member of the roster' do - include_context 'when logged in as a member of the roster' - - it 'responds with an forbidden status' do - call - expect(response).to have_http_status :forbidden - end - end - - context 'when logged in as the roster admin' do - include_context 'when logged in as an admin of the roster' - - it 'responds successfully' do - call - expect(response).to be_successful - end - end - end - - describe 'POST /rosters/:roster_id/assign_weeks' do - subject(:submit) { post "/rosters/#{roster.slug}/assign_weeks", params: { week_assigner: attributes } } - - let(:roster) { create :roster } - - context 'when logged in as a member of the roster' do - include_context 'when logged in as a member of the roster' - - let(:attributes) { { start_date: Date.current } } - - it 'responds with an forbidden status' do - submit - expect(response).to have_http_status :forbidden - end - end - - context 'when logged in as an admin of the roster with valid attributes' do - include_context 'when logged in as an admin of the roster' - - let(:attributes) do - { start_date: start_date, - end_date: start_date + 18.days, - starting_user_id: users.first.id, - user_ids: users.map(&:id) } - end - let(:users) { create_list :user, 2, rosters: [roster] } - let(:start_date) { Date.current.beginning_of_week(:sunday) } - - it 'redirects to the roster assignments page' do - submit - expect(response).to redirect_to roster_path(roster, date: start_date) - end - - it 'creates new assignments' do - expect { submit }.to change(Assignment, :count).by(3) - end - - it 'creates new assignments with the right attributes' do - submit - expect(Assignment.last(3)).to contain_exactly( - have_attributes('roster_id' => roster.id, 'user_id' => users.first.id, - 'start_date' => start_date + 2.weeks, 'end_date' => start_date + 2.weeks + 4.days), - have_attributes('roster_id' => roster.id, 'user_id' => users.second.id, - 'start_date' => start_date + 1.week, 'end_date' => start_date + 1.week + 6.days), - have_attributes('roster_id' => roster.id, 'user_id' => users.first.id, - 'start_date' => start_date, 'end_date' => start_date + 6.days) - ) - end - end - - context 'when logged in as an admin of the roster with invalid attributes' do - include_context 'when logged in as an admin of the roster' - - let(:attributes) { { start_date: Date.current } } - - it 'responds with an unprocessable content status' do - submit - expect(response).to have_http_status(:unprocessable_content) - end - end - end -end diff --git a/spec/requests/weekday_assigners_spec.rb b/spec/requests/weekday_assigners_spec.rb deleted file mode 100644 index 98605555..00000000 --- a/spec/requests/weekday_assigners_spec.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe 'Weekday Assigners' do - describe 'GET /rosters/:roster_id/assign_weekdays' do - subject(:call) { get "/rosters/#{roster.slug}/assign_weekdays" } - - let(:roster) { create :roster } - - context 'when logged in as a member of the roster' do - include_context 'when logged in as a member of the roster' - - it 'responds with an forbidden status' do - call - expect(response).to have_http_status :forbidden - end - end - - context 'when logged in as the roster admin' do - include_context 'when logged in as an admin of the roster' - - it 'responds successfully' do - call - expect(response).to be_successful - end - end - end - - describe 'POST /rosters/:roster_id/assign_weekdays' do - subject(:submit) { post "/rosters/#{roster.slug}/assign_weekdays", params: { weekday_assigner: attributes } } - - let(:roster) { create :roster } - - context 'when logged in as a member of the roster' do - include_context 'when logged in as a member of the roster' - - let(:attributes) { { start_date: Date.current } } - - it 'responds with an forbidden status' do - submit - expect(response).to have_http_status :forbidden - end - end - - context 'when logged in as an admin of the roster with valid attributes' do - include_context 'when logged in as an admin of the roster' - - let(:user) { create :user, rosters: [roster] } - let(:attributes) do - { user_id: user.id, - start_date: Date.current.beginning_of_week(:sunday) + 3, - end_date: 1.week.from_now.to_date.beginning_of_week(:sunday) + 3, - start_weekday: 2, - end_weekday: 4 } - end - - it 'redirects to the roster assignments page' do - submit - expect(response).to redirect_to roster_path(roster, date: Date.current.beginning_of_week(:sunday) + 3) - end - - it 'creates new assignments' do - expect { submit }.to change(Assignment, :count).by(2) - end - - it 'creates new assignments with the correct attributes' do - submit - expect(Assignment.last(2)).to contain_exactly( - have_attributes('roster_id' => roster.id, - 'user_id' => user.id, - 'start_date' => Date.current.beginning_of_week(:sunday) + 3, - 'end_date' => Date.current.beginning_of_week(:sunday) + 4), - have_attributes('roster_id' => roster.id, - 'user_id' => user.id, - 'start_date' => 1.week.from_now.to_date.beginning_of_week(:sunday) + 2, - 'end_date' => 1.week.from_now.to_date.beginning_of_week(:sunday) + 3) - ) - end - end - - context 'when logged in as an admin of the roster with invalid attributes' do - include_context 'when logged in as an admin of the roster' - - let(:attributes) { { start_date: Date.current } } - - it 'responds with an unprocessable content status' do - submit - expect(response).to have_http_status(:unprocessable_content) - end - end - end -end