From c6575ed15a6b941075bcb83b7027c1b58d96b53e Mon Sep 17 00:00:00 2001 From: Jesse Landis-Eigsti Date: Mon, 22 Jul 2024 08:34:11 -0600 Subject: [PATCH 01/94] Replaces reminder_day with reminder_schedule Via db migrations, replaces the simple integer `reminder_day` with the more complex `reminder_schedule` which is an ical string that can be parsed to a repeating Schedule class. Adjusts the logic in the `fetch_partners_to_reminder_now_service` to use the repeating schedule. Updates related tests. --- Gemfile | 2 + Gemfile.lock | 1 + .../admin/organizations_controller.rb | 2 +- app/controllers/organizations_controller.rb | 2 +- app/controllers/partner_groups_controller.rb | 2 +- app/models/concerns/deadlinable.rb | 37 +++++++++++++-- app/models/organization.rb | 2 +- app/models/partner_group.rb | 18 +++---- .../fetch_partners_to_remind_now_service.rb | 19 ++++++-- ..._add_reminder_schedule_to_organizations.rb | 6 +++ ...40715162837_seed_reminder_schedule_data.rb | 17 +++++++ ..._remove_reminder_day_from_organizations.rb | 6 +++ db/schema.rb | 5 +- spec/factories/organizations.rb | 9 ++-- spec/factories/partner_groups.rb | 24 ++++++---- spec/mailers/reminder_deadline_mailer_spec.rb | 3 +- spec/models/concerns/deadlinable_spec.rb | 26 ++++++---- spec/models/organization_spec.rb | 21 +++++---- spec/models/partner_group_spec.rb | 26 +++++----- ...tch_partners_to_remind_now_service_spec.rb | 47 +++++++++++++++---- 20 files changed, 196 insertions(+), 79 deletions(-) create mode 100644 db/migrate/20240715155823_add_reminder_schedule_to_organizations.rb create mode 100644 db/migrate/20240715162837_seed_reminder_schedule_data.rb create mode 100644 db/migrate/20240715163348_remove_reminder_day_from_organizations.rb diff --git a/Gemfile b/Gemfile index 1110f5c6f6..12a4c60996 100644 --- a/Gemfile +++ b/Gemfile @@ -92,6 +92,8 @@ gem "flipper-ui" gem "geocoder" # Generate .ics calendars for use with Google Calendar gem 'icalendar', require: false +# Offers functionality for date reocccurances +gem "ice_cube" # JSON Web Token encoding / decoding (e.g. for links in e-mails) gem "jwt" # Use Newrelic for logs and APM diff --git a/Gemfile.lock b/Gemfile.lock index 1554ddba6a..e27933163e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -780,6 +780,7 @@ DEPENDENCIES guard-rspec icalendar importmap-rails (~> 2.1) + ice_cube jbuilder jwt kaminari diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb index f81aaef2cf..92175a9272 100644 --- a/app/controllers/admin/organizations_controller.rb +++ b/app/controllers/admin/organizations_controller.rb @@ -86,7 +86,7 @@ def destroy def organization_params params.require(:organization) - .permit(:name, :short_name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_email_text, :account_request_id, :reminder_day, :deadline_day, + .permit(:name, :short_name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_email_text, :account_request_id, :reminder_schedule, :deadline_day, users_attributes: %i(name email organization_admin), account_request_attributes: %i(ndbn_member_id id)) end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 536f2d81e9..5de17e7635 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -95,7 +95,7 @@ def organization_params :name, :short_name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_storage_location, :default_email_text, :reminder_email_text, - :invitation_text, :reminder_day, :deadline_day, + :invitation_text, :reminder_schedule, :deadline_day, :repackage_essentials, :distribute_monthly, :ndbn_member_id, :enable_child_based_requests, :enable_individual_requests, :enable_quantity_based_requests, diff --git a/app/controllers/partner_groups_controller.rb b/app/controllers/partner_groups_controller.rb index aeb981f740..967fb06247 100644 --- a/app/controllers/partner_groups_controller.rb +++ b/app/controllers/partner_groups_controller.rb @@ -52,7 +52,7 @@ def set_partner_group end def partner_group_params - params.require(:partner_group).permit(:name, :send_reminders, :deadline_day, :reminder_day, item_category_ids: []) + params.require(:partner_group).permit(:name, :send_reminders, :deadline_day, :reminder_schedule, item_category_ids: []) end def set_items_categories diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index dfb35c6d64..608b0d36d9 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -1,15 +1,44 @@ module Deadlinable extend ActiveSupport::Concern - MIN_DAY_OF_MONTH = 1 MAX_DAY_OF_MONTH = 28 included do validates :deadline_day, numericality: {only_integer: true, less_than_or_equal_to: MAX_DAY_OF_MONTH, greater_than_or_equal_to: MIN_DAY_OF_MONTH, allow_nil: true} - validates :reminder_day, numericality: {only_integer: true, less_than_or_equal_to: MAX_DAY_OF_MONTH, - greater_than_or_equal_to: MIN_DAY_OF_MONTH, allow_nil: true} + validate :reminder_on_deadline_day? + validate :reminder_schedule_is_within_range? + end + + def convert_to_reminder_schedule(day) + schedule = IceCube::Schedule.new + schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(day) + schedule.to_ical + end + + private + + def reminder_on_deadline_day? + if reminder_schedule.nil? + return + end + + schedule = IceCube::Schedule.from_ical reminder_schedule + if schedule.first.day == deadline_day + errors.add(:reminder_schedule, "Reminder must not be the same as deadline date") + end + end - validates :reminder_day, numericality: {other_than: :deadline_day}, if: :deadline_day? + def reminder_schedule_is_within_range? + if reminder_schedule.nil? + return + end + schedule = IceCube::Schedule.from_ical reminder_schedule + reminder_day = schedule.first.day + # IceCube converts negative or zero days to valid days (e.g. -1 becomes the last day of the month, 0 becomes 1) + # The minimum check should no longer be necessary, but keeping it in case IceCube changes + if reminder_day < 0 || reminder_day > MAX_DAY_OF_MONTH + errors.add(:reminder_schedule, "Reminder day must be between #{MIN_DAY_OF_MONTH} and #{MAX_DAY_OF_MONTH}") + end end end diff --git a/app/models/organization.rb b/app/models/organization.rb index 5c70feaf7c..019cbb1fff 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -21,7 +21,7 @@ # one_step_partner_invite :boolean default(FALSE), not null # partner_form_fields :text default([]), is an Array # receive_email_on_requests :boolean default(FALSE), not null -# reminder_day :integer +# reminder_schedule :string saved in iCal format, eg "RRULE:FREQ=MONTHLY;BYMONTHDAY=14" # repackage_essentials :boolean default(FALSE), not null # short_name :string # signature_for_distribution_pdf :boolean default(FALSE) diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index 715aa101b2..9b751011b0 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -2,14 +2,14 @@ # # Table name: partner_groups # -# id :bigint not null, primary key -# deadline_day :integer -# name :string -# reminder_day :integer -# send_reminders :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :bigint +# id :bigint not null, primary key +# deadline_day :integer +# name :string +# reminder_schedule :string saved in iCal format, eg "RRULE:FREQ=MONTHLY;BYMONTHDAY=14" +# send_reminders :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :bigint # class PartnerGroup < ApplicationRecord has_paper_trail @@ -20,5 +20,5 @@ class PartnerGroup < ApplicationRecord has_and_belongs_to_many :item_categories validates :name, presence: true, uniqueness: { scope: :organization } - validates :deadline_day, :reminder_day, presence: true, if: :send_reminders? + validates :deadline_day, :reminder_schedule, presence: true, if: :send_reminders? end diff --git a/app/services/partners/fetch_partners_to_remind_now_service.rb b/app/services/partners/fetch_partners_to_remind_now_service.rb index 0f112b76d5..f61187f140 100644 --- a/app/services/partners/fetch_partners_to_remind_now_service.rb +++ b/app/services/partners/fetch_partners_to_remind_now_service.rb @@ -1,22 +1,31 @@ module Partners class FetchPartnersToRemindNowService def fetch - current_day = Time.current.day + current_day = Time.current deactivated_status = ::Partner.statuses[:deactivated] partners_with_group_reminders = ::Partner.left_joins(:partner_group) - .where(partner_groups: {reminder_day: current_day}) + .where.not(partner_groups: {reminder_schedule: nil}) .where.not(partner_groups: {deadline_day: nil}) .where.not(status: deactivated_status) + # where partner groups have reminder schedule match + filtered_partner_groups = partners_with_group_reminders.select do |partner| + sched = IceCube::Schedule.from_ical partner.partner_group.reminder_schedule + sched.occurs_on?(current_day) + end + partners_with_only_organization_reminders = ::Partner.left_joins(:partner_group, :organization) - .where(partner_groups: {reminder_day: nil}) + .where(partner_groups: {reminder_schedule: nil}) .where(send_reminders: true) - .where(organizations: {reminder_day: current_day}) .where.not(organizations: {deadline_day: nil}) .where.not(status: deactivated_status) - (partners_with_group_reminders + partners_with_only_organization_reminders).flatten.uniq + filtered_organizations = partners_with_only_organization_reminders.select do |partner| + sched = IceCube::Schedule.from_ical partner.organization.reminder_schedule + sched.occurs_on?(current_day) + end + (filtered_partner_groups + filtered_organizations).flatten.uniq end end end diff --git a/db/migrate/20240715155823_add_reminder_schedule_to_organizations.rb b/db/migrate/20240715155823_add_reminder_schedule_to_organizations.rb new file mode 100644 index 0000000000..4da938f248 --- /dev/null +++ b/db/migrate/20240715155823_add_reminder_schedule_to_organizations.rb @@ -0,0 +1,6 @@ +class AddReminderScheduleToOrganizations < ActiveRecord::Migration[7.1] + def change + add_column :organizations, :reminder_schedule, :string + add_column :partner_groups, :reminder_schedule, :string + end +end diff --git a/db/migrate/20240715162837_seed_reminder_schedule_data.rb b/db/migrate/20240715162837_seed_reminder_schedule_data.rb new file mode 100644 index 0000000000..5fdc96bbe7 --- /dev/null +++ b/db/migrate/20240715162837_seed_reminder_schedule_data.rb @@ -0,0 +1,17 @@ +class SeedReminderScheduleData < ActiveRecord::Migration[7.1] + def change + for o in Organization.all + if o.reminder_day.present? + reminder_schedule = o.convert_to_reminder_schedule(o.reminder_day) + o.update(reminder_schedule: reminder_schedule) + end + end + + for pg in PartnerGroup.all + if pg.reminder_day.present? + reminder_schedule = pg.convert_to_reminder_schedule(pg.reminder_day) + pg.update(reminder_schedule: reminder_schedule) + end + end + end +end diff --git a/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb b/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb new file mode 100644 index 0000000000..db0d3e0dfc --- /dev/null +++ b/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb @@ -0,0 +1,6 @@ +class RemoveReminderDayFromOrganizations < ActiveRecord::Migration[7.1] + def change + safety_assured { remove_column :organizations, :reminder_day } + safety_assured { remove_column :partner_groups, :reminder_day } + end +end diff --git a/db/schema.rb b/db/schema.rb index 74fcca3d68..bd2dff6c79 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -477,7 +477,6 @@ t.string "zipcode" t.float "latitude" t.float "longitude" - t.integer "reminder_day" t.integer "deadline_day" t.text "invitation_text" t.integer "default_storage_location" @@ -495,6 +494,7 @@ t.boolean "hide_package_column_on_receipt", default: false t.boolean "signature_for_distribution_pdf", default: false t.boolean "receive_email_on_requests", default: false, null: false + t.string "reminder_schedule" t.index ["latitude", "longitude"], name: "index_organizations_on_latitude_and_longitude" t.index ["short_name"], name: "index_organizations_on_short_name" end @@ -512,12 +512,11 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.boolean "send_reminders", default: false, null: false - t.integer "reminder_day" t.integer "deadline_day" + t.string "reminder_schedule" t.index ["name", "organization_id"], name: "index_partner_groups_on_name_and_organization_id", unique: true t.index ["organization_id"], name: "index_partner_groups_on_organization_id" t.check_constraint "deadline_day <= 28", name: "deadline_day_of_month_check" - t.check_constraint "reminder_day <= 28", name: "reminder_day_of_month_check" end create_table "partner_profiles", force: :cascade do |t| diff --git a/spec/factories/organizations.rb b/spec/factories/organizations.rb index 87f176dc30..59aa4b546b 100644 --- a/spec/factories/organizations.rb +++ b/spec/factories/organizations.rb @@ -21,7 +21,7 @@ # one_step_partner_invite :boolean default(FALSE), not null # partner_form_fields :text default([]), is an Array # receive_email_on_requests :boolean default(FALSE), not null -# reminder_day :integer +# reminder_schedule :string # repackage_essentials :boolean default(FALSE), not null # short_name :string # signature_for_distribution_pdf :boolean default(FALSE) @@ -43,6 +43,9 @@ skip_items { false } end + recurrence_schedule = IceCube::Schedule.new + recurrence_schedule.add_recurrence_rule IceCube::Rule.monthly(1).day_of_month(10) + recurrence_schedule_ical = recurrence_schedule.to_ical sequence(:name) { |n| "Essentials Bank #{n}" } # 037000863427 sequence(:short_name) { |n| "db_#{n}" } # 037000863427 sequence(:email) { |n| "email#{n}@example.com" } # 037000863427 @@ -51,13 +54,13 @@ city { 'Front Royal' } state { 'VA' } zipcode { '22630' } - reminder_day { 10 } + reminder_schedule { recurrence_schedule_ical } deadline_day { 20 } logo { Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/logo.jpg"), "image/jpeg") } trait :without_deadlines do - reminder_day { nil } + reminder_schedule { nil } deadline_day { nil } end diff --git a/spec/factories/partner_groups.rb b/spec/factories/partner_groups.rb index b3723feda1..99a74042fa 100644 --- a/spec/factories/partner_groups.rb +++ b/spec/factories/partner_groups.rb @@ -2,25 +2,29 @@ # # Table name: partner_groups # -# id :bigint not null, primary key -# deadline_day :integer -# name :string -# reminder_day :integer -# send_reminders :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :bigint +# id :bigint not null, primary key +# deadline_day :integer +# name :string +# reminder_schedule :string +# send_reminders :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :bigint # FactoryBot.define do + recurrence_schedule = IceCube::Schedule.new + recurrence_schedule.add_recurrence_rule IceCube::Rule.monthly(1).day_of_month(10) + recurrence_schedule_ical = recurrence_schedule.to_ical + factory :partner_group do sequence(:name) { |n| "Group #{n}" } organization { Organization.try(:first) || create(:organization) } - reminder_day { 14 } + reminder_schedule { recurrence_schedule_ical } deadline_day { 28 } trait :without_deadlines do - reminder_day { nil } + reminder_schedule { nil } deadline_day { nil } end end diff --git a/spec/mailers/reminder_deadline_mailer_spec.rb b/spec/mailers/reminder_deadline_mailer_spec.rb index 51c71c55c5..5752407de2 100644 --- a/spec/mailers/reminder_deadline_mailer_spec.rb +++ b/spec/mailers/reminder_deadline_mailer_spec.rb @@ -4,10 +4,9 @@ describe 'notify deadline' do let(:today) { Date.new(2022, 1, 10) } let(:partner) { create(:partner, organization: organization) } - before(:each) do organization.reminder_email_text = "Custom reminder message" - organization.update!(reminder_day: today.day, deadline_day: 1) + organization.update!(deadline_day: 1) end subject { described_class.notify_deadline(partner) } diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index 4034b5bc5f..6f6214d540 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -8,7 +8,7 @@ def self.name include ActiveModel::Model include Deadlinable - attr_accessor :deadline_day, :reminder_day + attr_accessor :deadline_day, :reminder_schedule def deadline_day? !!deadline_day @@ -17,6 +17,12 @@ def deadline_day? end subject(:dummy) { dummy_class.new } + let(:current_day) { Time.current } + let(:schedule) { IceCube::Schedule.new(current_day) } + + before do + dummy.deadline_day = 7 + end describe "validations" do it do @@ -27,20 +33,20 @@ def deadline_day? .allow_nil end - it do - is_expected.to validate_numericality_of(:reminder_day) - .only_integer - .is_greater_than_or_equal_to(1) - .is_less_than_or_equal_to(28) - .allow_nil + it "validates that the reminder schedule's date fall within the range" do + schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(31) + dummy.reminder_schedule = schedule.to_ical + + expect(dummy).not_to be_valid + expect(dummy.errors.added?(:reminder_schedule, "Reminder day must be between 1 and 28")).to be_truthy end it "validates that reminder day is not the same as deadline day" do - dummy.deadline_day = 7 - dummy.reminder_day = 7 + schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(7) + dummy.reminder_schedule = schedule.to_ical expect(dummy).not_to be_valid - expect(dummy.errors.added?(:reminder_day, "must be other than 7")).to be_truthy + expect(dummy.errors.added?(:reminder_schedule, "Reminder must not be the same as deadline date")).to be_truthy end end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index ae3f5d7cc8..d38d79c13a 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -21,7 +21,7 @@ # one_step_partner_invite :boolean default(FALSE), not null # partner_form_fields :text default([]), is an Array # receive_email_on_requests :boolean default(FALSE), not null -# reminder_day :integer +# reminder_schedule :string # repackage_essentials :boolean default(FALSE), not null # short_name :string # signature_for_distribution_pdf :boolean default(FALSE) @@ -379,13 +379,18 @@ end end - describe 'reminder_day' do - it "can only contain numbers 1-28" do - expect(build(:organization, reminder_day: 28)).to be_valid - expect(build(:organization, reminder_day: 1)).to be_valid - expect(build(:organization, reminder_day: 0)).to_not be_valid - expect(build(:organization, reminder_day: -5)).to_not be_valid - expect(build(:organization, reminder_day: 29)).to_not be_valid + describe 'reminder_schedule' do + it "cannot exceed 28" do + schedule = IceCube::Schedule.new(Date.new(2022, 1, 1)) + valid_days = [1, 28] + valid_days.each do |day| + schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(day) + expect(build(:organization, reminder_schedule: schedule.to_ical)).to be_valid + schedule.remove_recurrence_rule(schedule.recurrence_rules.first) + end + schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(29) + expect(build(:organization, reminder_schedule: schedule.to_ical)).to_not be_valid + schedule.remove_recurrence_rule(schedule.recurrence_rules.first) end end describe 'deadline_day' do diff --git a/spec/models/partner_group_spec.rb b/spec/models/partner_group_spec.rb index 10030476ce..14cdb2900b 100644 --- a/spec/models/partner_group_spec.rb +++ b/spec/models/partner_group_spec.rb @@ -2,14 +2,14 @@ # # Table name: partner_groups # -# id :bigint not null, primary key -# deadline_day :integer -# name :string -# reminder_day :integer -# send_reminders :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :bigint +# id :bigint not null, primary key +# deadline_day :integer +# name :string +# reminder_schedule :string +# send_reminders :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :bigint # RSpec.describe PartnerGroup, type: :model do describe 'associations' do @@ -34,9 +34,11 @@ end end - describe 'reminder_day <= 28' do + describe 'reminder_schedule day <= 28' do it 'raises error if unmet' do - expect { partner_group.update_column(:reminder_day, 29) }.to raise_error(ActiveRecord::StatementInvalid) + schedule = IceCube::Schedule.new(Time.current) + schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(30) + expect(build(:partner_group, reminder_schedule: schedule.to_ical)).to_not be_valid end end end @@ -54,8 +56,8 @@ expect(build(:partner, name: "Foo", organization: build(:organization))).to be_valid end - describe "deadline_day && reminder_day must be defined if send_reminders=true" do - let(:partner_group) { build(:partner_group, send_reminders: true, deadline_day: nil, reminder_day: nil) } + describe "deadline_day && reminder_schedule must be defined if send_reminders=true" do + let(:partner_group) { build(:partner_group, send_reminders: true, deadline_day: nil, reminder_schedule: nil) } it "should not be valid" do expect(partner_group).not_to be_valid diff --git a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb index 2c3c90b23a..d7a8fd55da 100644 --- a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb +++ b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb @@ -2,15 +2,18 @@ describe ".fetch" do subject { described_class.new.fetch } let(:current_day) { 14 } + let(:schedule_1) { IceCube::Schedule.new(Date.new(2022, 6, 1)) } + let(:schedule_2) { IceCube::Schedule.new(Date.new(2022, 6, 1)) } + let(:schedule_3) { IceCube::Schedule.new(Date.new(2022, 5, 1)) } before { travel_to(Time.zone.local(2022, 6, current_day, 1, 1, 1)) } context "when there is a partner" do let!(:partner) { create(:partner) } - context "that has an organization with a global reminder & deadline" do context "that is for today" do before do - partner.organization.update(reminder_day: current_day) + schedule_1.add_recurrence_rule IceCube::Rule.monthly.day_of_month(current_day) + partner.organization.update(reminder_schedule: schedule_1.to_ical) partner.organization.update(deadline_day: current_day + 2) end @@ -18,6 +21,28 @@ expect(subject).to include(partner) end + context "as matched by day of the week" do + before do + schedule_2.add_recurrence_rule IceCube::Rule.monthly.day_of_week(tuesday: [2]) + partner.organization.update(reminder_schedule: schedule_2.to_ical) + end + it "should include that partner" do + expect Time.current.day == 2 + expect(subject).to include(partner) + end + end + + context "but the reoccurrence rule is not for the current month" do + before do + schedule_3.add_recurrence_rule IceCube::Rule.monthly(2).day_of_month(current_day) + partner.organization.update(reminder_schedule: schedule_3.to_ical) + end + + it "should NOT include that partner" do + expect(subject).not_to include(partner) + end + end + context "but the partner is deactivated" do before do partner.deactivated! @@ -41,7 +66,8 @@ context "that is not for today" do before do - partner.organization.update(reminder_day: current_day - 1) + new_schedule = IceCube::Schedule.new(Date.new(2022, 6, current_day - 1)).to_ical + partner.organization.update(reminder_schedule: new_schedule) partner.organization.update(deadline_day: current_day + 2) end @@ -52,11 +78,12 @@ context "AND a partner group that does have them defined" do before do - partner_group = create(:partner_group, reminder_day: current_day, deadline_day: current_day + 2) + schedule_1.add_recurrence_rule IceCube::Rule.monthly.day_of_month(current_day) + schedule_2.add_recurrence_rule IceCube::Rule.monthly.day_of_month(current_day - 1) + partner_group = create(:partner_group, reminder_schedule: schedule_1.to_ical, deadline_day: current_day + 2) partner_group.partners << partner - partner.organization.update(reminder_day: current_day - 1) - partner.organization.update(deadline_day: current_day + 2) + partner.organization.update(reminder_schedule: schedule_2.to_ical) end it "should remind based on the partner group instead of the organization level reminder" do @@ -77,13 +104,14 @@ context "that does NOT have a organization with a global reminder & deadline" do before do - partner.organization.update(reminder_day: nil, deadline_day: nil) + partner.organization.update(reminder_schedule: nil, deadline_day: nil) end context "and is a part of a partner group that does have them defined" do context "that is for today" do before do - partner_group = create(:partner_group, reminder_day: current_day, deadline_day: current_day + 2) + schedule_1.add_recurrence_rule IceCube::Rule.monthly.day_of_month(current_day) + partner_group = create(:partner_group, reminder_schedule: schedule_1.to_ical, deadline_day: current_day + 2) partner_group.partners << partner end @@ -104,7 +132,8 @@ context "that is not for today" do before do - partner_group = create(:partner_group, reminder_day: current_day - 1, deadline_day: current_day + 2) + new_schedule = IceCube::Schedule.new(Date.new(2022, 6, current_day - 1)) + partner_group = create(:partner_group, reminder_schedule: new_schedule.to_ical, deadline_day: current_day + 2) partner_group.partners << partner end From da6e6eed052be6782d5cd7ad472dc188b0602d75 Mon Sep 17 00:00:00 2001 From: Jesse Landis-Eigsti Date: Tue, 20 Aug 2024 09:09:59 -0600 Subject: [PATCH 02/94] Adds input for the reminder schedule in the Edit Partner Group page Builds an ActiveModel, ReminderSchedule, which takes the necessary information and turns it into an IceCubeSchedule, which is what ultimate get saved in the db. Builds the form for this reminder schedule. Still needs to conditionally show or hide sections depending on if the user wants date or day of the week. --- Gemfile.lock | 7 +++ app/controllers/partner_groups_controller.rb | 11 +++- app/models/reminder_schedule.rb | 54 +++++++++++++++++++ .../shared/_deadline_day_fields.html.erb | 51 +++++++++++++++--- 4 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 app/models/reminder_schedule.rb diff --git a/Gemfile.lock b/Gemfile.lock index e27933163e..da92999364 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -564,6 +564,12 @@ GEM rdoc (6.13.1) psych (>= 4.0.0) recaptcha (5.19.0) + recurring_select (3.0.1) + coffee-rails (>= 3.1) + ice_cube (>= 0.11) + jquery-rails (>= 3.0) + rails (>= 5.2) + sass-rails (>= 4.0) regexp_parser (2.10.0) reline (0.6.0) io-console (~> 0.5) @@ -811,6 +817,7 @@ DEPENDENCIES rails-controller-testing rails-erd recaptcha + recurring_select rolify (~> 6.0) rspec-rails (~> 7.1.0) rubocop diff --git a/app/controllers/partner_groups_controller.rb b/app/controllers/partner_groups_controller.rb index 967fb06247..1200b116a0 100644 --- a/app/controllers/partner_groups_controller.rb +++ b/app/controllers/partner_groups_controller.rb @@ -21,11 +21,14 @@ def create def edit @partner_group = current_organization.partner_groups.find(params[:id]) set_items_categories + @reminder_schedule = ReminderSchedule.from_ical(@partner_group.reminder_schedule) + @item_categories = current_organization.item_categories end def update @partner_group = current_organization.partner_groups.find(params[:id]) - if @partner_group.update(partner_group_params) + reminder_schedule = ReminderSchedule.new(reminder_schedule_params).create_schedule + if @partner_group.update(partner_group_params.merge!(reminder_schedule:)) redirect_to partners_path + "#nav-partner-groups", notice: "Partner group edited!" else flash.now[:error] = "Something didn't work quite right -- try again?" @@ -52,7 +55,11 @@ def set_partner_group end def partner_group_params - params.require(:partner_group).permit(:name, :send_reminders, :deadline_day, :reminder_schedule, item_category_ids: []) + params.require(:partner_group).permit(:name, :send_reminders, :reminder_schedule, :deadline_day, item_category_ids: []) + end + + def reminder_schedule_params + params.require(:reminder_schedule).permit(:every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day) end def set_items_categories diff --git a/app/models/reminder_schedule.rb b/app/models/reminder_schedule.rb new file mode 100644 index 0000000000..303fb4d2ce --- /dev/null +++ b/app/models/reminder_schedule.rb @@ -0,0 +1,54 @@ +class ReminderSchedule + include ActiveModel::Model + + attr_accessor :every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day + attr_reader :every_nth_collection, :week_day_collection, :date_or_week_day_collection + + validates :every_n_months, presence: true, inclusion: 1..12 + validates :date_or_week_day, presence: true, inclusion: { in: %w[date week_day] } + validates :date, presence: true, if: -> { date_or_week_day == 'date' } + validates :day_of_week, presence: true, if: -> { date_or_week_day == 'week_day' }, inclusion: 1..7 + validates :every_nth_day, presence: true, if: -> { date_or_week_day == 'date' }, inclusion: 1..4 + + + def initialize(attributes = {}) + super + @every_n_months = every_n_months.to_i + @date = date.to_i + @day_of_week = day_of_week.to_i + @every_nth_day = every_nth_day.to_i + @every_nth_collection = EVERY_NTH_COLLECTION + @week_day_collection = WEEK_DAY_COLLECTION + @date_or_week_day_collection = DATE_OR_WEEK_DAY_COLLECTION + end + + def create_schedule + binding.pry + schedule = IceCube::Schedule.new(Time.zone.now.to_date) + if date_or_week_day == 'date' + schedule.add_recurrence_rule(IceCube::Rule.monthly(every_n_months).day_of_month(date)) + else + schedule.add_recurrence_rule(IceCube::Rule.monthly(every_n_months).day_of_week(day_of_week => [every_nth_day])) + end + schedule.to_ical + end + + def self.from_ical(ical) + schedule = IceCube::Schedule.from_ical(ical) + rule = schedule.recurrence_rules.first.instance_values + date = rule["validations"][:day_of_month]&.first&.value + new( + every_n_months: rule['interval'], + date_or_week_day: date ? 'date' : 'week_day', + date: date, + day_of_week: rule["validations"][:day_of_week]&.first&.day, + every_nth_day: rule["validations"][:day_of_week]&.first&.occ + ) + end + + private + EVERY_NTH_COLLECTION = [['First', 1], ['Second', 2], ['Third', 3], ['Fourth', 4], ['Last', -1]].freeze + WEEK_DAY_COLLECTION = [['Monday', 0], ['Tuesday', 1], ['Wednesday', 2], ['Thursday', 3], ['Friday', 4], ['Saturday', 5], ['Sunday', 6]].freeze + DATE_OR_WEEK_DAY_COLLECTION = [['date', 'Date'] ,['week_day', 'Day of the Week']].freeze + +end diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index 75f977c861..deb398292f 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -6,16 +6,51 @@ current_month: Date::MONTHNAMES[Date.current.month], next_month: Date::MONTHNAMES[Date.current.next_month.month] }) do %> -
- <%= f.input :reminder_day, wrapper: :input_group, wrapper_html: { class: 'mb-0' }, - label: 'Default reminder day (day of month an email reminder to submit Requests is sent to Partners)' do %> + + <%= simple_fields_for @reminder_schedule do |t| %> + <%= t.input :every_n_months, + as: :integer, + label: 'Send reminders every X months', + class: "deadline-day-pickers__reminder-day form-control", + hint: "Enter the number of months between reminders", + :input_html => {:style=> 'width: 100px'} + %> + + <%= t.input :date_or_week_day, wrapper: :input_group, wrapper_html: { class: 'mb-3' }, + label: 'Send reminders on a specific date (eg "the 5th") or a day of the week (eg "the first Tuesday")?' do %> + <%= t.collection_radio_buttons :date_or_week_day, t.object.date_or_week_day_collection, :first, :last do |b| %> + <%= b.label(class:"input-group-text mr-3") { b.radio_button + b.text } %> + <% end %> + <% end %> + + <%= t.input :date, wrapper: :input_group, wrapper_html: { class: 'mb-3' }, + label: 'Reminder date' do %> - <%= f.number_field :reminder_day, - class: "deadline-day-pickers__reminder-day form-control", - placeholder: "Reminder day" %> + <%= t.number_field :date, as: :integer, + class: "deadline-day-pickers__reminder-day form-control", + placeholder: "Reminder day", + :input_html => {:style=> 'width: 100px'} + %> + <% end %> + + <%= t.input :every_nth_day, wrapper: :input_group, wrapper_html: { class: 'mb-3'}, + label: 'Reminder day of the week' do %> + <%= t.input :every_nth_day, collection: t.object.every_nth_collection, + class: "deadline-day-pickers__reminder-day form-control", + label: false, + default: 1, + :input_html => {} + %> + + <%= t.input :day_of_week, collection: t.object.week_day_collection, + class: "deadline-day-pickers__reminder-day form-control", + label: false, + default: 0, + :input_html => {:style=> 'width: 200px'} + %> + <% end %> + <% end %> - -
<%= f.input :deadline_day, wrapper: :input_group, wrapper_html: { class: 'mb-0' }, From 46954b1c607cd570dc54807545662a5581f76353 Mon Sep 17 00:00:00 2001 From: Jesse Landis-Eigsti Date: Tue, 20 Aug 2024 11:11:49 -0600 Subject: [PATCH 03/94] Adds Reminder Schedule fields to New and Edit Organization Now either while creating new Organization/Partner Groups or editing them, all the transfers from Reminder Date to Reminder Day have been made. --- .../admin/organizations_controller.rb | 14 +- app/controllers/organizations_controller.rb | 9 +- app/controllers/partner_groups_controller.rb | 5 +- app/models/reminder_schedule.rb | 18 ++- app/views/admin/organizations/new.html.erb | 3 +- app/views/organizations/_details.html.erb | 134 ++++++++++++++++++ .../partners/_partner_groups_table.html.erb | 2 +- .../shared/_deadline_day_fields.html.erb | 6 +- 8 files changed, 174 insertions(+), 17 deletions(-) diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb index 92175a9272..bb0cc0762a 100644 --- a/app/controllers/admin/organizations_controller.rb +++ b/app/controllers/admin/organizations_controller.rb @@ -2,12 +2,13 @@ class Admin::OrganizationsController < AdminController def edit @organization = Organization.find(params[:id]) + @reminder_schedule = ReminderSchedule.from_ical(@organization.reminder_schedule) end def update @organization = Organization.find(params[:id]) - - if OrganizationUpdateService.update(@organization, organization_params) + reminder_schedule = ReminderSchedule.new(reminder_schedule_params).create_schedule + if OrganizationUpdateService.update(@organization, organization_params.merge!(reminder_schedule:)) redirect_to admin_organizations_path, notice: "Updated organization!" else flash.now[:error] = @organization.errors.full_messages.join("\n") @@ -31,6 +32,7 @@ def index def new @organization = Organization.new + @reminder_schedule = ReminderSchedule.new account_request = params[:token] && AccountRequest.get_by_identity_token(params[:token]) @user = User.new @@ -45,9 +47,11 @@ def new end def create - @organization = Organization.new(organization_params) @user = User.new(user_params) + @reminder_schedule = ReminderSchedule.new(reminder_schedule_params) + final_params = organization_params.merge!(reminder_schedule: @reminder_schedule.create_schedule) + @organization = Organization.new(final_params) if @organization.save Organization.seed_items(@organization) UserInviteService.invite(name: user_params[:name], @@ -93,4 +97,8 @@ def organization_params def user_params params.require(:organization).require(:user).permit(:name, :email) end + + def reminder_schedule_params + params.require(:reminder_schedule).permit(:every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day) + end end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 5de17e7635..e506ce8607 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -13,12 +13,13 @@ def show def edit @organization = current_organization + @reminder_schedule = ReminderSchedule.from_ical(@organization.reminder_schedule) end def update @organization = current_organization - - if OrganizationUpdateService.update(@organization, organization_params) + @reminder_schedule = ReminderSchedule.new(reminder_schedule_params) + if OrganizationUpdateService.update(@organization, organization_params.merge!(reminder_schedule:@reminder_schedule.create_schedule)) redirect_to organization_path, notice: "Updated your organization!" else flash.now[:error] = @organization.errors.full_messages.join("\n") @@ -107,6 +108,10 @@ def organization_params ) end + def reminder_schedule_params + params.require(:reminder_schedule).permit(:every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day) + end + def request_type_formatter(params) if params[:organization][:enable_individual_requests] == "false" params[:organization][:enable_child_based_requests] = false diff --git a/app/controllers/partner_groups_controller.rb b/app/controllers/partner_groups_controller.rb index 1200b116a0..089646bfe4 100644 --- a/app/controllers/partner_groups_controller.rb +++ b/app/controllers/partner_groups_controller.rb @@ -4,10 +4,13 @@ class PartnerGroupsController < ApplicationController def new @partner_group = current_organization.partner_groups.new set_items_categories + @reminder_schedule = ReminderSchedule.new end def create - @partner_group = current_organization.partner_groups.new(partner_group_params) + @reminder_schedule = ReminderSchedule.new(reminder_schedule_params) + final_params = partner_group_params.merge!(reminder_schedule: @reminder_schedule.create_schedule) + @partner_group = current_organization.partner_groups.new(final_params) if @partner_group.save # Redirect to groups tab in Partner page. redirect_to partners_path + "#nav-partner-groups", notice: "Partner group added!" diff --git a/app/models/reminder_schedule.rb b/app/models/reminder_schedule.rb index 303fb4d2ce..8858812cef 100644 --- a/app/models/reminder_schedule.rb +++ b/app/models/reminder_schedule.rb @@ -13,17 +13,18 @@ class ReminderSchedule def initialize(attributes = {}) super + @every_nth_collection = EVERY_NTH_COLLECTION + @week_day_collection = WEEK_DAY_COLLECTION + @date_or_week_day_collection = DATE_OR_WEEK_DAY_COLLECTION + return if attributes.blank? + @every_n_months = every_n_months.to_i @date = date.to_i @day_of_week = day_of_week.to_i @every_nth_day = every_nth_day.to_i - @every_nth_collection = EVERY_NTH_COLLECTION - @week_day_collection = WEEK_DAY_COLLECTION - @date_or_week_day_collection = DATE_OR_WEEK_DAY_COLLECTION end def create_schedule - binding.pry schedule = IceCube::Schedule.new(Time.zone.now.to_date) if date_or_week_day == 'date' schedule.add_recurrence_rule(IceCube::Rule.monthly(every_n_months).day_of_month(date)) @@ -33,6 +34,11 @@ def create_schedule schedule.to_ical end + def self.show_description(ical) + schedule = IceCube::Schedule.from_ical(ical) + schedule.recurrence_rules.first.to_s + end + def self.from_ical(ical) schedule = IceCube::Schedule.from_ical(ical) rule = schedule.recurrence_rules.first.instance_values @@ -47,8 +53,8 @@ def self.from_ical(ical) end private - EVERY_NTH_COLLECTION = [['First', 1], ['Second', 2], ['Third', 3], ['Fourth', 4], ['Last', -1]].freeze - WEEK_DAY_COLLECTION = [['Monday', 0], ['Tuesday', 1], ['Wednesday', 2], ['Thursday', 3], ['Friday', 4], ['Saturday', 5], ['Sunday', 6]].freeze + EVERY_NTH_COLLECTION = [['First', 1], ['Second', 2], ['Third', 3], ['Fourth', 4]].freeze + WEEK_DAY_COLLECTION = [['Sunday'], ['Monday', 1], ['Tuesday', 2], ['Wednesday', 3], ['Thursday', 4], ['Friday', 5], ['Saturday', 6]].freeze DATE_OR_WEEK_DAY_COLLECTION = [['date', 'Date'] ,['week_day', 'Day of the Week']].freeze end diff --git a/app/views/admin/organizations/new.html.erb b/app/views/admin/organizations/new.html.erb index 62fd4906a8..cb574e4eb2 100644 --- a/app/views/admin/organizations/new.html.erb +++ b/app/views/admin/organizations/new.html.erb @@ -48,8 +48,7 @@ <%= f.input :city %> <%= f.input :state, collection: us_states, class: "form-control", placeholder: "state" %> <%= f.input :zipcode %> - <%= f.input :reminder_day, class: "form-control", placeholder: "Reminder day" %> - <%= f.input :deadline_day, class: "form-control", placeholder: "Deadline day" %> + <%= render 'shared/deadline_day_fields', f: f %> <%= f.simple_fields_for :account_request do |account_request| %> <%= account_request.input :ndbn_member, label: 'NDBN Membership', wrapper: :input_group do %> <%= account_request.association :ndbn_member, label_method: :full_name, value_method: :id, label: false %> diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb index aafa8aa6f4..ea7f2081b6 100644 --- a/app/views/organizations/_details.html.erb +++ b/app/views/organizations/_details.html.erb @@ -61,6 +61,140 @@ <% end %>

+
+

Reminder Schedule

+

+ <%= fa_icon "calendar" %> + <%= @organization.reminder_schedule.blank? ? 'Not defined' : ReminderSchedule.show_description(@organization.reminder_schedule) %> +

+
+
+

Deadline day

+

+ <%= fa_icon "calendar" %> + <%= @organization.deadline_day.blank? ? 'Not defined' : "The #{@organization.deadline_day.ordinalize} of each month" %> +

+
+
+

Default Intake Location

+

+ <%= fa_icon "building" %> + <%= StorageLocation.find_by(id: @organization.intake_location)&.name || "Not defined" %> +

+
+
+

Partner Profile Sections

+ <% if @organization.partner_form_fields.blank? %> +

Not Provided

+ <% else %> +
    + <% for partner_form_field in @organization.partner_form_fields %> +
  • + <%= display_partner_fields_value(partner_form_field) %> +
  • + <% end %> +
+ <% end %> +

+
+
+

Default Storage Location

+

+ <%= fa_icon "building-o" %> + <%= StorageLocation.find_by(id: @organization.default_storage_location)&.name || "Not defined" %> +

+
+
+

Custom Partner Invitation Message

+

+ <%= @organization.invitation_text.blank? ? "Not defined" : @organization.invitation_text %> +

+
+
+

Repackage Essentials?

+

+ <%= fa_icon "inbox" %> + <%= humanize_boolean(@organization.repackage_essentials) %> +

+
+
+

Distribute Monthly?

+

+ <%= fa_icon "paper-plane" %> + <%= humanize_boolean(@organization.distribute_monthly) %> +

+
+
+

Child Based Requests?

+

+ <%= fa_icon "child" %> + <%= humanize_boolean(@organization.enable_child_based_requests) %> +

+
+
+

Individual Requests?

+

+ <%= fa_icon "female" %> + <%= humanize_boolean(@organization.enable_individual_requests) %> +

+
+
+

Quantity Based Requests?

+

+ <%= fa_icon "group" %> + <%= humanize_boolean(@organization.enable_quantity_based_requests) %> +

+
+
+

Show Year-to-date values on distribution printout?

+

+ <%= humanize_boolean(@organization.ytd_on_distribution_printout) %> +

+
+
+

Include Signature Lines on Distribution Printout?

+

+ <%= humanize_boolean(@organization.signature_for_distribution_pdf) %> +

+
+
+

Use One step Partner invite and approve process?

+

+ <%= humanize_boolean(@organization.one_step_partner_invite) %> +

+
+
+

Hide value columns on receipt:

+

+ <%= humanize_boolean(@organization.hide_value_columns_on_receipt) %> +

+
+
+

Hide package column on receipt:

+

+ <%= humanize_boolean(@organization.hide_package_column_on_receipt) %> +

+
+ <% if @organization.logo.attached? %> +
+

Logo

+

+ <%= image_tag @organization.logo, class: "main_logo" %> +

+

+ + View Original + +

+ +
+ <% end %> +
Logo
<% if @organization.logo.attached? %> diff --git a/app/views/partners/_partner_groups_table.html.erb b/app/views/partners/_partner_groups_table.html.erb index 94778851c3..0627c82988 100644 --- a/app/views/partners/_partner_groups_table.html.erb +++ b/app/views/partners/_partner_groups_table.html.erb @@ -45,7 +45,7 @@ <% if pg.send_reminders %> - Reminder emails are sent on the <%= pg.reminder_day.ordinalize %> of every month. + Reminder emails are sent <%= ReminderSchedule.show_description(pg.reminder_schedule) %>.
Deadlines are the <%= pg.deadline_day.ordinalize %> of every month. <% else %> diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index deb398292f..0dccbb2fce 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -13,6 +13,7 @@ label: 'Send reminders every X months', class: "deadline-day-pickers__reminder-day form-control", hint: "Enter the number of months between reminders", + :placeholder => "1", :input_html => {:style=> 'width: 100px'} %> @@ -26,7 +27,7 @@ <%= t.input :date, wrapper: :input_group, wrapper_html: { class: 'mb-3' }, label: 'Reminder date' do %> - <%= t.number_field :date, as: :integer, + <%= t.number_field :date, class: "deadline-day-pickers__reminder-day form-control", placeholder: "Reminder day", :input_html => {:style=> 'width: 100px'} @@ -45,7 +46,8 @@ <%= t.input :day_of_week, collection: t.object.week_day_collection, class: "deadline-day-pickers__reminder-day form-control", label: false, - default: 0, + show_blank: true, + default: 1, :input_html => {:style=> 'width: 200px'} %> <% end %> From 2006168ac001d76cf102a89a51e3eae23f4b74cb Mon Sep 17 00:00:00 2001 From: Jesse Landis-Eigsti Date: Thu, 22 Aug 2024 10:55:21 -0600 Subject: [PATCH 04/94] Makes the day of week or date hide depending on selection This uses css for ease and accessibility. --- app/assets/stylesheets/custom.scss | 11 ++++++++++ .../shared/_deadline_day_fields.html.erb | 20 +++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/app/assets/stylesheets/custom.scss b/app/assets/stylesheets/custom.scss index 4c349ab669..8024e5f58d 100644 --- a/app/assets/stylesheets/custom.scss +++ b/app/assets/stylesheets/custom.scss @@ -112,3 +112,14 @@ max-width: 100%; } } +#week_day_fields, #date_fields { + display: none; +} + +#reminder_schedule_date_or_week_day_week_day:checked ~ #week_day_fields { + display: block; +} + +#reminder_schedule_date_or_week_day_date:checked ~ #date_fields { + display: block +} diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index 0dccbb2fce..3142aeac37 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -16,14 +16,16 @@ :placeholder => "1", :input_html => {:style=> 'width: 100px'} %> - - <%= t.input :date_or_week_day, wrapper: :input_group, wrapper_html: { class: 'mb-3' }, - label: 'Send reminders on a specific date (eg "the 5th") or a day of the week (eg "the first Tuesday")?' do %> - <%= t.collection_radio_buttons :date_or_week_day, t.object.date_or_week_day_collection, :first, :last do |b| %> - <%= b.label(class:"input-group-text mr-3") { b.radio_button + b.text } %> - <% end %> - <% end %> + <%= t.label :date_or_week_day, 'Send reminders on a specific date (eg "the 5th") or a day of the week (eg "the first Tuesday")?' %> +
+ <%= t.radio_button :date_or_week_day, 'date', label: 'Date'%> + <%= t.label :date_or_week_day, 'Date' %> +
+ <%= t.radio_button :date_or_week_day, 'week_day', label: 'Week Day' %> + <%= t.label :date_or_week_day, 'Week Day' %> + +
<%= t.input :date, wrapper: :input_group, wrapper_html: { class: 'mb-3' }, label: 'Reminder date' do %> @@ -33,7 +35,9 @@ :input_html => {:style=> 'width: 100px'} %> <% end %> +
+
<%= t.input :every_nth_day, wrapper: :input_group, wrapper_html: { class: 'mb-3'}, label: 'Reminder day of the week' do %> <%= t.input :every_nth_day, collection: t.object.every_nth_collection, @@ -50,8 +54,8 @@ default: 1, :input_html => {:style=> 'width: 200px'} %> +
<% end %> - <% end %>
From 521a4f3a4c7498d9f078a520729f8e8bb2dac7f7 Mon Sep 17 00:00:00 2001 From: Jesse Landis-Eigsti Date: Wed, 28 Aug 2024 08:02:05 -0600 Subject: [PATCH 05/94] Adds tests and does refactoring Adds a number of refactors, including moving all the create_schedule logic to the deadlinable helper, and creates unit and system tests for the new functionality. --- app/assets/stylesheets/custom.scss | 6 +- .../admin/organizations_controller.rb | 17 +-- app/controllers/organizations_controller.rb | 14 +- app/controllers/partner_groups_controller.rb | 19 +-- app/models/concerns/deadlinable.rb | 64 ++++++--- app/models/organization.rb | 6 +- app/models/partner_group.rb | 9 +- app/models/reminder_schedule.rb | 60 -------- .../fetch_partners_to_remind_now_service.rb | 1 + app/views/organizations/_details.html.erb | 134 ++++++++++++++++++ .../partners/_partner_groups_table.html.erb | 6 +- .../shared/_deadline_day_fields.html.erb | 50 +++---- ..._remove_reminder_day_from_organizations.rb | 4 +- spec/models/concerns/deadlinable_spec.rb | 32 ++++- spec/models/organization_spec.rb | 16 +-- spec/models/partner_group_spec.rb | 9 +- ...tch_partners_to_remind_now_service_spec.rb | 48 +++---- .../system/admin/organizations_system_spec.rb | 5 +- spec/system/organization_system_spec.rb | 117 +++++++++++++++ spec/system/partner_system_spec.rb | 26 ++++ 20 files changed, 442 insertions(+), 201 deletions(-) delete mode 100644 app/models/reminder_schedule.rb diff --git a/app/assets/stylesheets/custom.scss b/app/assets/stylesheets/custom.scss index 8024e5f58d..fdf417ced1 100644 --- a/app/assets/stylesheets/custom.scss +++ b/app/assets/stylesheets/custom.scss @@ -112,14 +112,14 @@ max-width: 100%; } } -#week_day_fields, #date_fields { +#week-day-fields, #date-fields { display: none; } -#reminder_schedule_date_or_week_day_week_day:checked ~ #week_day_fields { +#toggle-to-week-day:checked ~ #week-day-fields { display: block; } -#reminder_schedule_date_or_week_day_date:checked ~ #date_fields { +#toggle-to-date:checked ~ #date-fields { display: block } diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb index bb0cc0762a..0500a3d168 100644 --- a/app/controllers/admin/organizations_controller.rb +++ b/app/controllers/admin/organizations_controller.rb @@ -2,13 +2,12 @@ class Admin::OrganizationsController < AdminController def edit @organization = Organization.find(params[:id]) - @reminder_schedule = ReminderSchedule.from_ical(@organization.reminder_schedule) + @organization.from_ical(@organization.reminder_schedule) end def update @organization = Organization.find(params[:id]) - reminder_schedule = ReminderSchedule.new(reminder_schedule_params).create_schedule - if OrganizationUpdateService.update(@organization, organization_params.merge!(reminder_schedule:)) + if OrganizationUpdateService.update(@organization, organization_params) redirect_to admin_organizations_path, notice: "Updated organization!" else flash.now[:error] = @organization.errors.full_messages.join("\n") @@ -32,7 +31,7 @@ def index def new @organization = Organization.new - @reminder_schedule = ReminderSchedule.new + @organization.from_ical(@organization.reminder_schedule) account_request = params[:token] && AccountRequest.get_by_identity_token(params[:token]) @user = User.new @@ -50,8 +49,7 @@ def create @user = User.new(user_params) @reminder_schedule = ReminderSchedule.new(reminder_schedule_params) - final_params = organization_params.merge!(reminder_schedule: @reminder_schedule.create_schedule) - @organization = Organization.new(final_params) + @organization = Organization.new(organization_params) if @organization.save Organization.seed_items(@organization) UserInviteService.invite(name: user_params[:name], @@ -90,15 +88,12 @@ def destroy def organization_params params.require(:organization) - .permit(:name, :short_name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_email_text, :account_request_id, :reminder_schedule, :deadline_day, + .permit(:name, :short_name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_email_text, :account_request_id, + :every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day, :deadline_day, users_attributes: %i(name email organization_admin), account_request_attributes: %i(ndbn_member_id id)) end def user_params params.require(:organization).require(:user).permit(:name, :email) end - - def reminder_schedule_params - params.require(:reminder_schedule).permit(:every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day) - end end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index e506ce8607..2b0a6bfdb1 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -13,13 +13,12 @@ def show def edit @organization = current_organization - @reminder_schedule = ReminderSchedule.from_ical(@organization.reminder_schedule) + @organization.from_ical(@organization.reminder_schedule) end def update @organization = current_organization - @reminder_schedule = ReminderSchedule.new(reminder_schedule_params) - if OrganizationUpdateService.update(@organization, organization_params.merge!(reminder_schedule:@reminder_schedule.create_schedule)) + if OrganizationUpdateService.update(@organization, organization_params) redirect_to organization_path, notice: "Updated your organization!" else flash.now[:error] = @organization.errors.full_messages.join("\n") @@ -102,16 +101,13 @@ def organization_params :enable_individual_requests, :enable_quantity_based_requests, :ytd_on_distribution_printout, :one_step_partner_invite, :hide_value_columns_on_receipt, :hide_package_column_on_receipt, - :signature_for_distribution_pdf, :receive_email_on_requests, - partner_form_fields: [], + :signature_for_distribution_pdf, :every_n_months, + :date_or_week_day, :date, :day_of_week, :every_nth_day, + partner_form_fields: [] request_unit_names: [] ) end - def reminder_schedule_params - params.require(:reminder_schedule).permit(:every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day) - end - def request_type_formatter(params) if params[:organization][:enable_individual_requests] == "false" params[:organization][:enable_child_based_requests] = false diff --git a/app/controllers/partner_groups_controller.rb b/app/controllers/partner_groups_controller.rb index 089646bfe4..6683787e69 100644 --- a/app/controllers/partner_groups_controller.rb +++ b/app/controllers/partner_groups_controller.rb @@ -4,13 +4,11 @@ class PartnerGroupsController < ApplicationController def new @partner_group = current_organization.partner_groups.new set_items_categories - @reminder_schedule = ReminderSchedule.new + @item_categories = current_organization.item_categories end def create - @reminder_schedule = ReminderSchedule.new(reminder_schedule_params) - final_params = partner_group_params.merge!(reminder_schedule: @reminder_schedule.create_schedule) - @partner_group = current_organization.partner_groups.new(final_params) + @partner_group = current_organization.partner_groups.new(partner_group_params) if @partner_group.save # Redirect to groups tab in Partner page. redirect_to partners_path + "#nav-partner-groups", notice: "Partner group added!" @@ -24,14 +22,13 @@ def create def edit @partner_group = current_organization.partner_groups.find(params[:id]) set_items_categories - @reminder_schedule = ReminderSchedule.from_ical(@partner_group.reminder_schedule) + @partner_group.from_ical(@partner_group.reminder_schedule) @item_categories = current_organization.item_categories end def update @partner_group = current_organization.partner_groups.find(params[:id]) - reminder_schedule = ReminderSchedule.new(reminder_schedule_params).create_schedule - if @partner_group.update(partner_group_params.merge!(reminder_schedule:)) + if @partner_group.update(partner_group_params) redirect_to partners_path + "#nav-partner-groups", notice: "Partner group edited!" else flash.now[:error] = "Something didn't work quite right -- try again?" @@ -58,11 +55,9 @@ def set_partner_group end def partner_group_params - params.require(:partner_group).permit(:name, :send_reminders, :reminder_schedule, :deadline_day, item_category_ids: []) - end - - def reminder_schedule_params - params.require(:reminder_schedule).permit(:every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day) + params.require(:partner_group).permit(:name, :send_reminders, :reminder_schedule, + :deadline_day, :every_n_months, :date_or_week_day, + :date, :day_of_week, :every_nth_day, item_category_ids: []) end def set_items_categories diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index 608b0d36d9..4041e51a11 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -2,12 +2,20 @@ module Deadlinable extend ActiveSupport::Concern MIN_DAY_OF_MONTH = 1 MAX_DAY_OF_MONTH = 28 + EVERY_NTH_COLLECTION = [["First", 1], ["Second", 2], ["Third", 3], ["Fourth", 4]].freeze + WEEK_DAY_COLLECTION = [["Sunday"], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]].freeze included do + attr_accessor :every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day + attr_reader :every_nth_collection, :week_day_collection, :date_or_week_day_collection validates :deadline_day, numericality: {only_integer: true, less_than_or_equal_to: MAX_DAY_OF_MONTH, greater_than_or_equal_to: MIN_DAY_OF_MONTH, allow_nil: true} - validate :reminder_on_deadline_day? - validate :reminder_schedule_is_within_range? + validate :reminder_on_deadline_day?, if: -> { every_n_months.present? } + validate :reminder_is_within_range?, if: -> { every_n_months.present? } + validates :date_or_week_day, inclusion: {in: %w[date week_day]}, if: -> { every_n_months.present? } + validates :date, presence: true, if: -> { date_or_week_day == "date" && every_n_months.present? } + validates :day_of_week, presence: true, if: -> { date_or_week_day == "week_day" && every_n_months.present? }, inclusion: {in: %w[1 2 3 4 5 6 7]} + validates :every_nth_day, presence: true, if: -> { date_or_week_day == "week_day" && every_n_months.present? }, inclusion: {in: %w[1 2 3 4]} end def convert_to_reminder_schedule(day) @@ -16,29 +24,51 @@ def convert_to_reminder_schedule(day) schedule.to_ical end + def show_description(ical) + schedule = IceCube::Schedule.from_ical(ical) + schedule.recurrence_rules.first.to_s + end + + def from_ical(ical) + return if ical.blank? + schedule = IceCube::Schedule.from_ical(ical) + rule = schedule.recurrence_rules.first.instance_values + date = rule["validations"][:day_of_month]&.first&.value + self.every_n_months = rule["interval"] + self.date_or_week_day = date ? "date" : "week_day" + self.date = date + self.day_of_week = rule["validations"][:day_of_week]&.first&.day, + self.every_nth_day = rule["validations"][:day_of_week]&.first&.occ + rescue + nil + end + private def reminder_on_deadline_day? - if reminder_schedule.nil? - return - end - - schedule = IceCube::Schedule.from_ical reminder_schedule - if schedule.first.day == deadline_day - errors.add(:reminder_schedule, "Reminder must not be the same as deadline date") + if date_or_week_day == "date" && date.to_i == deadline_day + errors.add(:date, "Reminder must not be the same as deadline date") end end - def reminder_schedule_is_within_range? - if reminder_schedule.nil? - return - end - schedule = IceCube::Schedule.from_ical reminder_schedule - reminder_day = schedule.first.day + def reminder_is_within_range? # IceCube converts negative or zero days to valid days (e.g. -1 becomes the last day of the month, 0 becomes 1) # The minimum check should no longer be necessary, but keeping it in case IceCube changes - if reminder_day < 0 || reminder_day > MAX_DAY_OF_MONTH - errors.add(:reminder_schedule, "Reminder day must be between #{MIN_DAY_OF_MONTH} and #{MAX_DAY_OF_MONTH}") + if date_or_week_day == "date" && date.to_i < MIN_DAY_OF_MONTH || date.to_i > MAX_DAY_OF_MONTH + errors.add(:date, "Reminder day must be between #{MIN_DAY_OF_MONTH} and #{MAX_DAY_OF_MONTH}") end end + + def create_schedule + schedule = IceCube::Schedule.new(Time.zone.now.to_date) + return nil if every_n_months.blank? || every_n_months.to_i.zero? + if date_or_week_day == "date" + schedule.add_recurrence_rule(IceCube::Rule.monthly(every_n_months.to_i).day_of_month(date.to_i)) + else + schedule.add_recurrence_rule(IceCube::Rule.monthly(every_n_months.to_i).day_of_week(day_of_week.to_i => [every_nth_day.to_i])) + end + schedule.to_ical + rescue + nil + end end diff --git a/app/models/organization.rb b/app/models/organization.rb index 019cbb1fff..716541754d 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -21,7 +21,7 @@ # one_step_partner_invite :boolean default(FALSE), not null # partner_form_fields :text default([]), is an Array # receive_email_on_requests :boolean default(FALSE), not null -# reminder_schedule :string saved in iCal format, eg "RRULE:FREQ=MONTHLY;BYMONTHDAY=14" +# reminder_schedule :string # repackage_essentials :boolean default(FALSE), not null # short_name :string # signature_for_distribution_pdf :boolean default(FALSE) @@ -93,6 +93,10 @@ def upcoming end end + before_save do + self.reminder_schedule = create_schedule + end + after_create do account_request&.update!(status: "admin_approved") end diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index 9b751011b0..acd8563ed6 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -5,7 +5,7 @@ # id :bigint not null, primary key # deadline_day :integer # name :string -# reminder_schedule :string saved in iCal format, eg "RRULE:FREQ=MONTHLY;BYMONTHDAY=14" +# reminder_schedule :string # send_reminders :boolean default(FALSE), not null # created_at :datetime not null # updated_at :datetime not null @@ -19,6 +19,11 @@ class PartnerGroup < ApplicationRecord has_many :partners, dependent: :nullify has_and_belongs_to_many :item_categories + before_save do + self.reminder_schedule = create_schedule + end + + validates :organization, presence: true validates :name, presence: true, uniqueness: { scope: :organization } - validates :deadline_day, :reminder_schedule, presence: true, if: :send_reminders? + validates :deadline_day, presence: true, if: :send_reminders? end diff --git a/app/models/reminder_schedule.rb b/app/models/reminder_schedule.rb deleted file mode 100644 index 8858812cef..0000000000 --- a/app/models/reminder_schedule.rb +++ /dev/null @@ -1,60 +0,0 @@ -class ReminderSchedule - include ActiveModel::Model - - attr_accessor :every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day - attr_reader :every_nth_collection, :week_day_collection, :date_or_week_day_collection - - validates :every_n_months, presence: true, inclusion: 1..12 - validates :date_or_week_day, presence: true, inclusion: { in: %w[date week_day] } - validates :date, presence: true, if: -> { date_or_week_day == 'date' } - validates :day_of_week, presence: true, if: -> { date_or_week_day == 'week_day' }, inclusion: 1..7 - validates :every_nth_day, presence: true, if: -> { date_or_week_day == 'date' }, inclusion: 1..4 - - - def initialize(attributes = {}) - super - @every_nth_collection = EVERY_NTH_COLLECTION - @week_day_collection = WEEK_DAY_COLLECTION - @date_or_week_day_collection = DATE_OR_WEEK_DAY_COLLECTION - return if attributes.blank? - - @every_n_months = every_n_months.to_i - @date = date.to_i - @day_of_week = day_of_week.to_i - @every_nth_day = every_nth_day.to_i - end - - def create_schedule - schedule = IceCube::Schedule.new(Time.zone.now.to_date) - if date_or_week_day == 'date' - schedule.add_recurrence_rule(IceCube::Rule.monthly(every_n_months).day_of_month(date)) - else - schedule.add_recurrence_rule(IceCube::Rule.monthly(every_n_months).day_of_week(day_of_week => [every_nth_day])) - end - schedule.to_ical - end - - def self.show_description(ical) - schedule = IceCube::Schedule.from_ical(ical) - schedule.recurrence_rules.first.to_s - end - - def self.from_ical(ical) - schedule = IceCube::Schedule.from_ical(ical) - rule = schedule.recurrence_rules.first.instance_values - date = rule["validations"][:day_of_month]&.first&.value - new( - every_n_months: rule['interval'], - date_or_week_day: date ? 'date' : 'week_day', - date: date, - day_of_week: rule["validations"][:day_of_week]&.first&.day, - every_nth_day: rule["validations"][:day_of_week]&.first&.occ - ) - end - - private - EVERY_NTH_COLLECTION = [['First', 1], ['Second', 2], ['Third', 3], ['Fourth', 4]].freeze - WEEK_DAY_COLLECTION = [['Sunday'], ['Monday', 1], ['Tuesday', 2], ['Wednesday', 3], ['Thursday', 4], ['Friday', 5], ['Saturday', 6]].freeze - DATE_OR_WEEK_DAY_COLLECTION = [['date', 'Date'] ,['week_day', 'Day of the Week']].freeze - -end diff --git a/app/services/partners/fetch_partners_to_remind_now_service.rb b/app/services/partners/fetch_partners_to_remind_now_service.rb index f61187f140..c6e792fd9c 100644 --- a/app/services/partners/fetch_partners_to_remind_now_service.rb +++ b/app/services/partners/fetch_partners_to_remind_now_service.rb @@ -19,6 +19,7 @@ def fetch .where(partner_groups: {reminder_schedule: nil}) .where(send_reminders: true) .where.not(organizations: {deadline_day: nil}) + .where.not(organizations: {reminder_schedule: nil}) .where.not(status: deactivated_status) filtered_organizations = partners_with_only_organization_reminders.select do |partner| diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb index ea7f2081b6..76d1d7bdc8 100644 --- a/app/views/organizations/_details.html.erb +++ b/app/views/organizations/_details.html.erb @@ -61,6 +61,140 @@ <% end %>

+
+

Reminder Schedule

+

+ <%= fa_icon "calendar" %> + <%= @organization.reminder_schedule.blank? ? 'Not defined' : @organization.show_description(@organization.reminder_schedule) %> +

+
+
+

Deadline day

+

+ <%= fa_icon "calendar" %> + <%= @organization.deadline_day.blank? ? 'Not defined' : "The #{@organization.deadline_day.ordinalize} of each month" %> +

+
+
+

Default Intake Location

+

+ <%= fa_icon "building" %> + <%= StorageLocation.find_by(id: @organization.intake_location)&.name || "Not defined" %> +

+
+
+

Partner Profile Sections

+ <% if @organization.partner_form_fields.blank? %> +

Not Provided

+ <% else %> +
    + <% for partner_form_field in @organization.partner_form_fields %> +
  • + <%= display_partner_fields_value(partner_form_field) %> +
  • + <% end %> +
+ <% end %> +

+
+
+

Default Storage Location

+

+ <%= fa_icon "building-o" %> + <%= StorageLocation.find_by(id: @organization.default_storage_location)&.name || "Not defined" %> +

+
+
+

Custom Partner Invitation Message

+

+ <%= @organization.invitation_text.blank? ? "Not defined" : @organization.invitation_text %> +

+
+
+

Repackage Essentials?

+

+ <%= fa_icon "inbox" %> + <%= humanize_boolean(@organization.repackage_essentials) %> +

+
+
+

Distribute Monthly?

+

+ <%= fa_icon "paper-plane" %> + <%= humanize_boolean(@organization.distribute_monthly) %> +

+
+
+

Child Based Requests?

+

+ <%= fa_icon "child" %> + <%= humanize_boolean(@organization.enable_child_based_requests) %> +

+
+
+

Individual Requests?

+

+ <%= fa_icon "female" %> + <%= humanize_boolean(@organization.enable_individual_requests) %> +

+
+
+

Quantity Based Requests?

+

+ <%= fa_icon "group" %> + <%= humanize_boolean(@organization.enable_quantity_based_requests) %> +

+
+
+

Show Year-to-date values on distribution printout?

+

+ <%= humanize_boolean(@organization.ytd_on_distribution_printout) %> +

+
+
+

Include Signature Lines on Distribution Printout?

+

+ <%= humanize_boolean(@organization.signature_for_distribution_pdf) %> +

+
+
+

Use One step Partner invite and approve process?

+

+ <%= humanize_boolean(@organization.one_step_partner_invite) %> +

+
+
+

Hide value columns on receipt:

+

+ <%= humanize_boolean(@organization.hide_value_columns_on_receipt) %> +

+
+
+

Hide package column on receipt:

+

+ <%= humanize_boolean(@organization.hide_package_column_on_receipt) %> +

+
+ <% if @organization.logo.attached? %> +
+

Logo

+

+ <%= image_tag @organization.logo, class: "main_logo" %> +

+

+ + View Original + +

+ +
+ <% end %> +

Reminder Schedule

diff --git a/app/views/partners/_partner_groups_table.html.erb b/app/views/partners/_partner_groups_table.html.erb index 0627c82988..458e606e17 100644 --- a/app/views/partners/_partner_groups_table.html.erb +++ b/app/views/partners/_partner_groups_table.html.erb @@ -45,8 +45,10 @@ <% if pg.send_reminders %> - Reminder emails are sent <%= ReminderSchedule.show_description(pg.reminder_schedule) %>. -
+ <% if pg.reminder_schedule.present? %> + Reminder emails are sent <%= pg.show_description(pg.reminder_schedule) %>. +
+ <% end %> Deadlines are the <%= pg.deadline_day.ordinalize %> of every month. <% else %> No diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index 3142aeac37..e0104e5aef 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -1,68 +1,62 @@ <%= tag.div(class: 'deadline-day-pickers', data: { - min: Deadlinable::MIN_DAY_OF_MONTH, - max: Deadlinable::MAX_DAY_OF_MONTH, current_day: Date.current.mday, current_month: Date::MONTHNAMES[Date.current.month], next_month: Date::MONTHNAMES[Date.current.next_month.month] }) do %> - <%= simple_fields_for @reminder_schedule do |t| %> - <%= t.input :every_n_months, + <%= f.input :every_n_months, as: :integer, label: 'Send reminders every X months', class: "deadline-day-pickers__reminder-day form-control", hint: "Enter the number of months between reminders", :placeholder => "1", - :input_html => {:style=> 'width: 100px'} - %> + :input_html => {:style=> 'width: 100px', :min => 0, :max => 12} %> - <%= t.label :date_or_week_day, 'Send reminders on a specific date (eg "the 5th") or a day of the week (eg "the first Tuesday")?' %> + <%= f.label :date_or_week_day, 'Send reminders on a specific date (eg "the 5th") or a day of the week (eg "the first Tuesday")?' %>
- <%= t.radio_button :date_or_week_day, 'date', label: 'Date'%> - <%= t.label :date_or_week_day, 'Date' %> + <%= f.radio_button :date_or_week_day, 'date', label: 'Date', id: 'toggle-to-date' %> + <%= f.label :date_or_week_day, 'Date' %>
- <%= t.radio_button :date_or_week_day, 'week_day', label: 'Week Day' %> - <%= t.label :date_or_week_day, 'Week Day' %> + <%= f.radio_button :date_or_week_day, 'week_day', label: 'Week Day', id: 'toggle-to-week-day' %> + <%= f.label :date_or_week_day, 'Week Day' %> -

- <%= t.input :date, wrapper: :input_group, wrapper_html: { class: 'mb-3' }, +
+ <%= f.input :date, as: :integer, wrapper: :input_group, wrapper_html: { class: 'mb-3' }, label: 'Reminder date' do %> - <%= t.number_field :date, + <%= f.number_field :date, + min: Deadlinable::MIN_DAY_OF_MONTH, + max: Deadlinable::MAX_DAY_OF_MONTH, class: "deadline-day-pickers__reminder-day form-control", - placeholder: "Reminder day", - :input_html => {:style=> 'width: 100px'} - %> + placeholder: "Reminder day" %> <% end %>
-
- <%= t.input :every_nth_day, wrapper: :input_group, wrapper_html: { class: 'mb-3'}, +
+ <%= f.input :every_nth_day, wrapper: :input_group, wrapper_html: { class: 'mb-3'}, label: 'Reminder day of the week' do %> - <%= t.input :every_nth_day, collection: t.object.every_nth_collection, + <%= f.input :every_nth_day, collection: Deadlinable::EVERY_NTH_COLLECTION, class: "deadline-day-pickers__reminder-day form-control", - label: false, default: 1, - :input_html => {} - %> + label: false %> - <%= t.input :day_of_week, collection: t.object.week_day_collection, + <%= f.input :day_of_week, collection: Deadlinable::WEEK_DAY_COLLECTION, class: "deadline-day-pickers__reminder-day form-control", label: false, show_blank: true, default: 1, - :input_html => {:style=> 'width: 200px'} - %> + :input_html => {:style=> 'width: 200px'} %>
<% end %> - <% end %>
- <%= f.input :deadline_day, wrapper: :input_group, wrapper_html: { class: 'mb-0' }, + <%= f.input :deadline_day, wrapper: :input_group, wrapper_html: { class: 'mb-0', min: 0, max: 28 }, label: 'Default deadline day (final day of month to submit Requests)' do %> <%= f.number_field :deadline_day, + min: Deadlinable::MIN_DAY_OF_MONTH, + max: Deadlinable::MAX_DAY_OF_MONTH, class: "deadline-day-pickers__deadline-day form-control", placeholder: "Deadline day" %> <% end %> diff --git a/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb b/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb index db0d3e0dfc..df697e0c90 100644 --- a/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb +++ b/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb @@ -1,6 +1,6 @@ class RemoveReminderDayFromOrganizations < ActiveRecord::Migration[7.1] def change - safety_assured { remove_column :organizations, :reminder_day } - safety_assured { remove_column :partner_groups, :reminder_day } + safety_assured { remove_column :organizations, :reminder_day, :integer } + safety_assured { remove_column :partner_groups, :reminder_day, :integer } end end diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index 6f6214d540..aeb37e259d 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -33,20 +33,40 @@ def deadline_day? .allow_nil end + it 'validates the date field presence if date_or_week_day is "date"' do + dummy.every_n_months = 1 + dummy.date_or_week_day = "date" + is_expected.to validate_presence_of(:date) + end + + it 'validate the day_of_week field presence if date_or_week_day is "week_day"' do + dummy.every_n_months = 1 + dummy.date_or_week_day = "week_day" + is_expected.to validate_presence_of(:day_of_week) + is_expected.to validate_presence_of(:every_nth_day) + end + + it "validates the date_or_week_day field inclusion" do + dummy.every_n_months = 1 + is_expected.to validate_inclusion_of(:date_or_week_day).in_array(%w[date week_day]) + end + it "validates that the reminder schedule's date fall within the range" do - schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(31) - dummy.reminder_schedule = schedule.to_ical + dummy.every_n_months = 1 + dummy.date_or_week_day = "date" + dummy.date = 29 expect(dummy).not_to be_valid - expect(dummy.errors.added?(:reminder_schedule, "Reminder day must be between 1 and 28")).to be_truthy + expect(dummy.errors.added?(:date, "Reminder day must be between 1 and 28")).to be_truthy end it "validates that reminder day is not the same as deadline day" do - schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(7) - dummy.reminder_schedule = schedule.to_ical + dummy.every_n_months = 1 + dummy.date_or_week_day = "date" + dummy.date = dummy.deadline_day expect(dummy).not_to be_valid - expect(dummy.errors.added?(:reminder_schedule, "Reminder must not be the same as deadline date")).to be_truthy + expect(dummy.errors.added?(:date, "Reminder must not be the same as deadline date")).to be_truthy end end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index d38d79c13a..73d1225a1b 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -380,17 +380,11 @@ end describe 'reminder_schedule' do - it "cannot exceed 28" do - schedule = IceCube::Schedule.new(Date.new(2022, 1, 1)) - valid_days = [1, 28] - valid_days.each do |day| - schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(day) - expect(build(:organization, reminder_schedule: schedule.to_ical)).to be_valid - schedule.remove_recurrence_rule(schedule.recurrence_rules.first) - end - schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(29) - expect(build(:organization, reminder_schedule: schedule.to_ical)).to_not be_valid - schedule.remove_recurrence_rule(schedule.recurrence_rules.first) + it "cannot exceed 28 if date_or_week_day is date" do + expect(build(:organization, every_n_months: 1, date_or_week_day: 'date', date: 28)).to be_valid + expect(build(:organization, every_n_months: 1, date_or_week_day: 'date', date: 29)).to_not be_valid + expect(build(:organization, every_n_months: 1, date_or_week_day: 'date', date: 0)).to_not be_valid + expect(build(:organization, every_n_months: 1, date_or_week_day: 'date', date: -5)).to_not be_valid end end describe 'deadline_day' do diff --git a/spec/models/partner_group_spec.rb b/spec/models/partner_group_spec.rb index 14cdb2900b..76a2061eb4 100644 --- a/spec/models/partner_group_spec.rb +++ b/spec/models/partner_group_spec.rb @@ -28,17 +28,16 @@ end end - describe 'deadline_day <= 28' do + describe 'deadline_day > 28' do it 'raises error if unmet' do expect { partner_group.update_column(:deadline_day, 29) }.to raise_error(ActiveRecord::StatementInvalid) end end - describe 'reminder_schedule day <= 28' do + describe 'reminder_schedule day > 28 and <=0' do it 'raises error if unmet' do - schedule = IceCube::Schedule.new(Time.current) - schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(30) - expect(build(:partner_group, reminder_schedule: schedule.to_ical)).to_not be_valid + expect { partner_group.update!(every_n_months: 1, date_or_week_day: 'date', date: 29) }.to raise_error(ActiveRecord::RecordInvalid) + expect { partner_group.update!(every_n_months: 1, date_or_week_day: 'date', date: -5) }.to raise_error(ActiveRecord::RecordInvalid) end end end diff --git a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb index d7a8fd55da..87fd414243 100644 --- a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb +++ b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb @@ -2,9 +2,6 @@ describe ".fetch" do subject { described_class.new.fetch } let(:current_day) { 14 } - let(:schedule_1) { IceCube::Schedule.new(Date.new(2022, 6, 1)) } - let(:schedule_2) { IceCube::Schedule.new(Date.new(2022, 6, 1)) } - let(:schedule_3) { IceCube::Schedule.new(Date.new(2022, 5, 1)) } before { travel_to(Time.zone.local(2022, 6, current_day, 1, 1, 1)) } context "when there is a partner" do @@ -12,37 +9,27 @@ context "that has an organization with a global reminder & deadline" do context "that is for today" do before do - schedule_1.add_recurrence_rule IceCube::Rule.monthly.day_of_month(current_day) - partner.organization.update(reminder_schedule: schedule_1.to_ical) - partner.organization.update(deadline_day: current_day + 2) + partner.organization.update(every_n_months: 1, date_or_week_day: "date", date: current_day, deadline_day: current_day + 2) end it "should include that partner" do + schedule = IceCube::Schedule.from_ical partner.organization.reminder_schedule + expect(schedule.occurs_on?(Time.current)).to be_truthy expect(subject).to include(partner) end context "as matched by day of the week" do before do - schedule_2.add_recurrence_rule IceCube::Rule.monthly.day_of_week(tuesday: [2]) - partner.organization.update(reminder_schedule: schedule_2.to_ical) + partner.organization.update(every_n_months: 1, date_or_week_day: "week_day", + day_of_week: 2, every_nth_day: 2, deadline_day: current_day + 2) end it "should include that partner" do - expect Time.current.day == 2 + schedule = IceCube::Schedule.from_ical partner.organization.reminder_schedule + expect(schedule.occurs_on?(Time.current)).to be_truthy expect(subject).to include(partner) end end - context "but the reoccurrence rule is not for the current month" do - before do - schedule_3.add_recurrence_rule IceCube::Rule.monthly(2).day_of_month(current_day) - partner.organization.update(reminder_schedule: schedule_3.to_ical) - end - - it "should NOT include that partner" do - expect(subject).not_to include(partner) - end - end - context "but the partner is deactivated" do before do partner.deactivated! @@ -66,9 +53,8 @@ context "that is not for today" do before do - new_schedule = IceCube::Schedule.new(Date.new(2022, 6, current_day - 1)).to_ical - partner.organization.update(reminder_schedule: new_schedule) - partner.organization.update(deadline_day: current_day + 2) + partner.organization.update(every_n_months: 1, date_or_week_day: "date", + date: current_day - 1, deadline_day: current_day + 2) end it "should NOT include that partner" do @@ -78,12 +64,12 @@ context "AND a partner group that does have them defined" do before do - schedule_1.add_recurrence_rule IceCube::Rule.monthly.day_of_month(current_day) - schedule_2.add_recurrence_rule IceCube::Rule.monthly.day_of_month(current_day - 1) - partner_group = create(:partner_group, reminder_schedule: schedule_1.to_ical, deadline_day: current_day + 2) + partner_group = create(:partner_group, every_n_months: 1, date_or_week_day: "date", + date: current_day, deadline_day: current_day + 2) partner_group.partners << partner - partner.organization.update(reminder_schedule: schedule_2.to_ical) + partner.organization.update(every_n_months: 1, date_or_week_day: "date", + date: current_day - 1, deadline_day: current_day + 2) end it "should remind based on the partner group instead of the organization level reminder" do @@ -110,8 +96,8 @@ context "and is a part of a partner group that does have them defined" do context "that is for today" do before do - schedule_1.add_recurrence_rule IceCube::Rule.monthly.day_of_month(current_day) - partner_group = create(:partner_group, reminder_schedule: schedule_1.to_ical, deadline_day: current_day + 2) + partner_group = create(:partner_group, every_n_months: 1, date_or_week_day: "date", + date: current_day, deadline_day: current_day + 2) partner_group.partners << partner end @@ -132,8 +118,8 @@ context "that is not for today" do before do - new_schedule = IceCube::Schedule.new(Date.new(2022, 6, current_day - 1)) - partner_group = create(:partner_group, reminder_schedule: new_schedule.to_ical, deadline_day: current_day + 2) + partner_group = create(:partner_group, every_n_months: 1, date_or_week_day: "date", + date: current_day - 1, deadline_day: current_day + 2) partner_group.partners << partner end diff --git a/spec/system/admin/organizations_system_spec.rb b/spec/system/admin/organizations_system_spec.rb index a5272f51eb..887a740e0e 100644 --- a/spec/system/admin/organizations_system_spec.rb +++ b/spec/system/admin/organizations_system_spec.rb @@ -117,6 +117,10 @@ fill_in "organization_user_name", with: admin_user_params[:name] fill_in "organization_user_email", with: admin_user_params[:email] + fill_in "organization_every_n_months", with: 1 + choose 'toggle-to-date' + fill_in "organization_date", with: 1 + click_on "Save" end @@ -125,7 +129,6 @@ within("tr.#{org_params[:short_name]}") do first(:link, "View").click end - expect(page).to have_content(org_params[:name]) expect(page).to have_content("Remount") expect(page).to have_content("Front Royal") diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index 3f5cec01bb..c6af749666 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -48,6 +48,123 @@ sign_in(organization_admin) end + describe "Viewing the organization" do + it "can view organization details", :aggregate_failures do + organization.update!(one_step_partner_invite: true) + + visit organization_path + + expect(page.find("h1")).to have_text(organization.name) + expect(page).to have_link("Home", href: dashboard_path) + + expect(page).to have_content("Organization Info") + expect(page).to have_content("Contact Info") + expect(page).to have_content("Default email text") + expect(page).to have_content("Users") + expect(page).to have_content("Short Name") + expect(page).to have_content("URL") + expect(page).to have_content("Partner Profile Sections") + expect(page).to have_content("Custom Partner Invitation Message") + expect(page).to have_content("Child Based Requests?") + expect(page).to have_content("Individual Requests?") + expect(page).to have_content("Quantity Based Requests?") + expect(page).to have_content("Show Year-to-date values on distribution printout?") + expect(page).to have_content("Logo") + expect(page).to have_content("Use One step Partner invite and approve process?") + end + end + + describe "Editing the organization" do + before do + visit edit_organization_path + end + + it "is prompted with placeholder text and a more helpful error message to ensure correct URL format as a user" do + fill_in "Url", with: "www.diaperbase.com" + click_on "Save" + + fill_in "Url", with: "http://www.diaperbase.com" + click_on "Save" + expect(page.find(".alert")).to have_content "pdated" + end + + it "can set a reminder and a deadline day" do + # TODO: change here + fill_in "organization_every_n_months", with: 1 + choose 'toggle-to-week-day' + select "First", from: "organization_every_nth_day" + select "Friday", from: "organization_day_of_week" + + fill_in "organization_deadline_day", with: 16 + click_on "Save" + expect(page.find(".alert")).to have_content "Updated" + expect(page).to have_content("Monthly on the 1st Friday") + end + + it 'can select if the org repackages essentials' do + choose('organization[repackage_essentials]', option: true) + + click_on "Save" + expect(page).to have_content("Yes") + end + + it 'can select if the org distributes essentials monthly' do + choose('organization[distribute_monthly]', option: true) + + click_on "Save" + expect(page).to have_content("Yes") + end + + it 'can select if the org shows year-to-date values on the distribution printout' do + choose('organization[ytd_on_distribution_printout]', option: false) + + click_on "Save" + expect(page).to have_content("No") + end + + it 'can set a default storage location on the organization' do + select(store.name, from: 'Default Storage Location') + + click_on "Save" + expect(page).to have_content(store.name) + end + + it 'can set the NDBN Member ID' do + select(ndbn_member.full_name) + + click_on "Save" + expect(page).to have_content(ndbn_member.full_name) + end + + it 'can select and deselect Required Partner Fields' do + # select first option in from Required Partner Fields + select('Media Information', from: 'organization_partner_form_fields', visible: false) + click_on "Save" + expect(page).to have_content('Media Information') + expect(organization.reload.partner_form_fields).to eq(['media_information']) + # deselect previously chosen Required Partner Field + click_on "Edit" + unselect('Media Information', from: 'organization_partner_form_fields', visible: false) + click_on "Save" + expect(page).to_not have_content('Media Information') + expect(organization.reload.partner_form_fields).to eq([]) + end + + it "can disable if the org does NOT use single step invite and approve partner process" do + choose("organization[one_step_partner_invite]", option: false) + + click_on "Save" + expect(page).to have_content("No") + end + + it "can enable if the org uses single step invite and approve partner process" do + choose("organization[one_step_partner_invite]", option: true) + + click_on "Save" + expect(page).to have_content("Yes") + end + end + it "can add a new user to an organization" do allow(User).to receive(:invite!).and_return(true) visit organization_path diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index e8e143d3f1..3632863f40 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -627,6 +627,13 @@ # Click on the second item category find("input#partner_group_item_category_ids_#{item_category_2.id}").click + # Opt in to sending deadline reminders + check 'Yes' + + fill_in "partner_group_every_n_months", with: 1, wait: page_content_wait + choose 'toggle-to-date' + fill_in "partner_group_date", with: 1 + fill_in "partner_group_deadline_day", with: 25 find_button('Add Partner Group').click assert page.has_content? 'Group Name', wait: page_content_wait @@ -662,6 +669,25 @@ refute page.has_content? item_category_1.name assert page.has_content? item_category_2.name end + + it 'should be able to edit a custom reminder schedule' do + visit partners_path + + click_on 'Groups' + assert page.has_content? existing_partner_group.name, wait: page_content_wait + + click_on 'Edit' + # Opt in to sending deadline reminders + check 'Yes' + fill_in "partner_group_every_n_months", with: 2, wait: page_content_wait + choose 'toggle-to-week-day' + select "Second", from: "partner_group_every_nth_day" + select "Thursday", from: "partner_group_day_of_week" + fill_in "partner_group_deadline_day", with: 24 + + find_button('Update Partner Group').click + assert page.has_content? 'Every 2 months on the 2nd Thursday', wait: page_content_wait + end end end end From 2d54fb9251cfdd2fa13f2fc873dc40d4805cc25a Mon Sep 17 00:00:00 2001 From: Jesse Landis-Eigsti Date: Wed, 11 Sep 2024 14:38:14 -0600 Subject: [PATCH 06/94] 4473 - Makes same warning appear for reminder date Reintroduces the functionality of having warnings for having the reminder date the same as the deadline date. --- app/javascript/utils/deadline_day_pickers.js | 1 - app/views/shared/_deadline_day_fields.html.erb | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/utils/deadline_day_pickers.js b/app/javascript/utils/deadline_day_pickers.js index d538c4a76a..31a86dc459 100644 --- a/app/javascript/utils/deadline_day_pickers.js +++ b/app/javascript/utils/deadline_day_pickers.js @@ -28,7 +28,6 @@ $(document).ready(function () { if (reminder_day) { $(container).find(reminder_container_selector).find(server_validation_selector).remove(); - if (reminder_day === deadline_day) { $reminder_text.removeClass('text-muted').addClass('text-danger'); diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index e0104e5aef..07b3b53042 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -22,7 +22,7 @@ <%= f.label :date_or_week_day, 'Week Day' %>
- <%= f.input :date, as: :integer, wrapper: :input_group, wrapper_html: { class: 'mb-3' }, + <%= f.input :date, as: :integer, wrapper: :input_group, label: 'Reminder date' do %> <%= f.number_field :date, @@ -32,6 +32,7 @@ placeholder: "Reminder day" %> <% end %>
+
<%= f.input :every_nth_day, wrapper: :input_group, wrapper_html: { class: 'mb-3'}, From e3f70d57a3c38a4f98a4edd252be95e595e4e5d4 Mon Sep 17 00:00:00 2001 From: Jesse Landis-Eigsti Date: Thu, 12 Sep 2024 10:38:06 -0600 Subject: [PATCH 07/94] Only create new schedule if details have changed We don't want to create a new schedule if nothing has changed, as it will reset the start date and potentially mess with those who have every 2/3/etc months reminders. --- .../admin/organizations_controller.rb | 4 +- app/controllers/organizations_controller.rb | 4 +- app/controllers/partner_groups_controller.rb | 2 +- app/models/concerns/deadlinable.rb | 40 +++++++++++++++---- app/models/organization.rb | 4 +- .../shared/_deadline_day_fields.html.erb | 1 - ...tch_partners_to_remind_now_service_spec.rb | 12 +++--- 7 files changed, 47 insertions(+), 20 deletions(-) diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb index 0500a3d168..6559a0192c 100644 --- a/app/controllers/admin/organizations_controller.rb +++ b/app/controllers/admin/organizations_controller.rb @@ -2,7 +2,7 @@ class Admin::OrganizationsController < AdminController def edit @organization = Organization.find(params[:id]) - @organization.from_ical(@organization.reminder_schedule) + @organization.get_values_from_reminder_schedule end def update @@ -31,7 +31,7 @@ def index def new @organization = Organization.new - @organization.from_ical(@organization.reminder_schedule) + @organization.get_values_from_reminder_schedule account_request = params[:token] && AccountRequest.get_by_identity_token(params[:token]) @user = User.new diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 2b0a6bfdb1..08259118ca 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -13,7 +13,7 @@ def show def edit @organization = current_organization - @organization.from_ical(@organization.reminder_schedule) + @organization.get_values_from_reminder_schedule end def update @@ -103,7 +103,7 @@ def organization_params :hide_value_columns_on_receipt, :hide_package_column_on_receipt, :signature_for_distribution_pdf, :every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day, - partner_form_fields: [] + partner_form_fields: [], request_unit_names: [] ) end diff --git a/app/controllers/partner_groups_controller.rb b/app/controllers/partner_groups_controller.rb index 6683787e69..78fc3b478d 100644 --- a/app/controllers/partner_groups_controller.rb +++ b/app/controllers/partner_groups_controller.rb @@ -22,7 +22,7 @@ def create def edit @partner_group = current_organization.partner_groups.find(params[:id]) set_items_categories - @partner_group.from_ical(@partner_group.reminder_schedule) + @partner_group.get_values_from_reminder_schedule @item_categories = current_organization.item_categories end diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index 4041e51a11..3d10705410 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -3,7 +3,7 @@ module Deadlinable MIN_DAY_OF_MONTH = 1 MAX_DAY_OF_MONTH = 28 EVERY_NTH_COLLECTION = [["First", 1], ["Second", 2], ["Third", 3], ["Fourth", 4]].freeze - WEEK_DAY_COLLECTION = [["Sunday"], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]].freeze + WEEK_DAY_COLLECTION = [["Sunday", 0], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]].freeze included do attr_accessor :every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day @@ -14,7 +14,7 @@ module Deadlinable validate :reminder_is_within_range?, if: -> { every_n_months.present? } validates :date_or_week_day, inclusion: {in: %w[date week_day]}, if: -> { every_n_months.present? } validates :date, presence: true, if: -> { date_or_week_day == "date" && every_n_months.present? } - validates :day_of_week, presence: true, if: -> { date_or_week_day == "week_day" && every_n_months.present? }, inclusion: {in: %w[1 2 3 4 5 6 7]} + validates :day_of_week, presence: true, if: -> { date_or_week_day == "week_day" && every_n_months.present? }, inclusion: {in: %w[0 1 2 3 4 5 6]} validates :every_nth_day, presence: true, if: -> { date_or_week_day == "week_day" && every_n_months.present? }, inclusion: {in: %w[1 2 3 4]} end @@ -34,15 +34,29 @@ def from_ical(ical) schedule = IceCube::Schedule.from_ical(ical) rule = schedule.recurrence_rules.first.instance_values date = rule["validations"][:day_of_month]&.first&.value - self.every_n_months = rule["interval"] - self.date_or_week_day = date ? "date" : "week_day" - self.date = date - self.day_of_week = rule["validations"][:day_of_week]&.first&.day, - self.every_nth_day = rule["validations"][:day_of_week]&.first&.occ + + results = {} + results[:every_n_months] = rule["interval"] + results[:date_or_week_day] = date ? "date" : "week_day" + results[:date] = date + results[:day_of_week] = rule["validations"][:day_of_week]&.first&.day + results[:every_nth_day] = rule["validations"][:day_of_week]&.first&.occ + results rescue nil end + def get_values_from_reminder_schedule + return if reminder_schedule.blank? + results = from_ical(reminder_schedule) + return if results.nil? + self.every_n_months = results[:every_n_months] + self.date_or_week_day = results[:date_or_week_day] + self.date = results[:date] + self.day_of_week = results[:day_of_week] + self.every_nth_day = results[:every_nth_day] + end + private def reminder_on_deadline_day? @@ -59,6 +73,18 @@ def reminder_is_within_range? end end + def should_update_reminder_schedule + if reminder_schedule.blank? + return every_n_months.present? + end + sched = from_ical(reminder_schedule) + every_n_months != sched[:every_n_months].presence.to_s || + date_or_week_day != sched[:date_or_week_day].presence.to_s || + date != sched[:date].presence.to_s || + day_of_week != sched[:day_of_week].presence.to_s || + every_nth_day != sched[:every_nth_day].presence.to_s + end + def create_schedule schedule = IceCube::Schedule.new(Time.zone.now.to_date) return nil if every_n_months.blank? || every_n_months.to_i.zero? diff --git a/app/models/organization.rb b/app/models/organization.rb index 716541754d..936c44d5db 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -94,7 +94,9 @@ def upcoming end before_save do - self.reminder_schedule = create_schedule + if should_update_reminder_schedule + self.reminder_schedule = create_schedule + end end after_create do diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index 07b3b53042..8a84f8c940 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -39,7 +39,6 @@ label: 'Reminder day of the week' do %> <%= f.input :every_nth_day, collection: Deadlinable::EVERY_NTH_COLLECTION, class: "deadline-day-pickers__reminder-day form-control", - default: 1, label: false %> <%= f.input :day_of_week, collection: Deadlinable::WEEK_DAY_COLLECTION, diff --git a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb index 87fd414243..146322f9eb 100644 --- a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb +++ b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb @@ -21,7 +21,7 @@ context "as matched by day of the week" do before do partner.organization.update(every_n_months: 1, date_or_week_day: "week_day", - day_of_week: 2, every_nth_day: 2, deadline_day: current_day + 2) + day_of_week: 2, every_nth_day: 2, deadline_day: current_day + 2) end it "should include that partner" do schedule = IceCube::Schedule.from_ical partner.organization.reminder_schedule @@ -54,7 +54,7 @@ context "that is not for today" do before do partner.organization.update(every_n_months: 1, date_or_week_day: "date", - date: current_day - 1, deadline_day: current_day + 2) + date: current_day - 1, deadline_day: current_day + 2) end it "should NOT include that partner" do @@ -65,11 +65,11 @@ context "AND a partner group that does have them defined" do before do partner_group = create(:partner_group, every_n_months: 1, date_or_week_day: "date", - date: current_day, deadline_day: current_day + 2) + date: current_day, deadline_day: current_day + 2) partner_group.partners << partner partner.organization.update(every_n_months: 1, date_or_week_day: "date", - date: current_day - 1, deadline_day: current_day + 2) + date: current_day - 1, deadline_day: current_day + 2) end it "should remind based on the partner group instead of the organization level reminder" do @@ -97,7 +97,7 @@ context "that is for today" do before do partner_group = create(:partner_group, every_n_months: 1, date_or_week_day: "date", - date: current_day, deadline_day: current_day + 2) + date: current_day, deadline_day: current_day + 2) partner_group.partners << partner end @@ -119,7 +119,7 @@ context "that is not for today" do before do partner_group = create(:partner_group, every_n_months: 1, date_or_week_day: "date", - date: current_day - 1, deadline_day: current_day + 2) + date: current_day - 1, deadline_day: current_day + 2) partner_group.partners << partner end From fca6b8faf0a54a67adbed7ff71602757aad5db19 Mon Sep 17 00:00:00 2001 From: Jesse Landis-Eigsti Date: Thu, 19 Sep 2024 14:40:54 -0600 Subject: [PATCH 08/94] 4473 - Removes Every N Months All reminders should be Monthly, so every N Months is removed. Also allows users to select "Last" as an option. --- .../admin/organizations_controller.rb | 2 +- app/controllers/organizations_controller.rb | 4 +-- app/controllers/partner_groups_controller.rb | 3 +- app/models/concerns/deadlinable.rb | 30 +++++++++---------- .../shared/_deadline_day_fields.html.erb | 8 ----- spec/models/concerns/deadlinable_spec.rb | 27 +++++++++-------- spec/models/organization_spec.rb | 8 ++--- spec/models/partner_group_spec.rb | 4 +-- ...tch_partners_to_remind_now_service_spec.rb | 14 ++++----- .../system/admin/organizations_system_spec.rb | 1 - spec/system/partner_system_spec.rb | 6 ++-- 11 files changed, 47 insertions(+), 60 deletions(-) diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb index 6559a0192c..863de0396f 100644 --- a/app/controllers/admin/organizations_controller.rb +++ b/app/controllers/admin/organizations_controller.rb @@ -89,7 +89,7 @@ def destroy def organization_params params.require(:organization) .permit(:name, :short_name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_email_text, :account_request_id, - :every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day, :deadline_day, + :date_or_week_day, :date, :day_of_week, :every_nth_day, :deadline_day, users_attributes: %i(name email organization_admin), account_request_attributes: %i(ndbn_member_id id)) end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 08259118ca..eb1be18e16 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -101,8 +101,8 @@ def organization_params :enable_individual_requests, :enable_quantity_based_requests, :ytd_on_distribution_printout, :one_step_partner_invite, :hide_value_columns_on_receipt, :hide_package_column_on_receipt, - :signature_for_distribution_pdf, :every_n_months, - :date_or_week_day, :date, :day_of_week, :every_nth_day, + :signature_for_distribution_pdf, :date_or_week_day, :date, :day_of_week, + :every_nth_day, partner_form_fields: [], request_unit_names: [] ) diff --git a/app/controllers/partner_groups_controller.rb b/app/controllers/partner_groups_controller.rb index 78fc3b478d..dccd70e8c3 100644 --- a/app/controllers/partner_groups_controller.rb +++ b/app/controllers/partner_groups_controller.rb @@ -56,8 +56,7 @@ def set_partner_group def partner_group_params params.require(:partner_group).permit(:name, :send_reminders, :reminder_schedule, - :deadline_day, :every_n_months, :date_or_week_day, - :date, :day_of_week, :every_nth_day, item_category_ids: []) + :deadline_day, :date_or_week_day, :date, :day_of_week, :every_nth_day, item_category_ids: []) end def set_items_categories diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index 3d10705410..2a9fd6d063 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -2,20 +2,19 @@ module Deadlinable extend ActiveSupport::Concern MIN_DAY_OF_MONTH = 1 MAX_DAY_OF_MONTH = 28 - EVERY_NTH_COLLECTION = [["First", 1], ["Second", 2], ["Third", 3], ["Fourth", 4]].freeze + EVERY_NTH_COLLECTION = [["First", 1], ["Second", 2], ["Third", 3], ["Fourth", 4], ["Last", -1]].freeze WEEK_DAY_COLLECTION = [["Sunday", 0], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]].freeze included do - attr_accessor :every_n_months, :date_or_week_day, :date, :day_of_week, :every_nth_day + attr_accessor :date_or_week_day, :date, :day_of_week, :every_nth_day attr_reader :every_nth_collection, :week_day_collection, :date_or_week_day_collection validates :deadline_day, numericality: {only_integer: true, less_than_or_equal_to: MAX_DAY_OF_MONTH, greater_than_or_equal_to: MIN_DAY_OF_MONTH, allow_nil: true} - validate :reminder_on_deadline_day?, if: -> { every_n_months.present? } - validate :reminder_is_within_range?, if: -> { every_n_months.present? } - validates :date_or_week_day, inclusion: {in: %w[date week_day]}, if: -> { every_n_months.present? } - validates :date, presence: true, if: -> { date_or_week_day == "date" && every_n_months.present? } - validates :day_of_week, presence: true, if: -> { date_or_week_day == "week_day" && every_n_months.present? }, inclusion: {in: %w[0 1 2 3 4 5 6]} - validates :every_nth_day, presence: true, if: -> { date_or_week_day == "week_day" && every_n_months.present? }, inclusion: {in: %w[1 2 3 4]} + validate :reminder_on_deadline_day?, if: -> { date.present? } + validate :reminder_is_within_range?, if: -> { date.present? } + validates :date_or_week_day, inclusion: {in: %w[date week_day]}, if: -> { date_or_week_day.present? } + validates :day_of_week, if: -> { day_of_week.present? }, inclusion: {in: %w[0 1 2 3 4 5 6]} + validates :every_nth_day, if: -> { every_nth_day.present? }, inclusion: {in: %w[1 2 3 4 -1]} end def convert_to_reminder_schedule(day) @@ -36,7 +35,6 @@ def from_ical(ical) date = rule["validations"][:day_of_month]&.first&.value results = {} - results[:every_n_months] = rule["interval"] results[:date_or_week_day] = date ? "date" : "week_day" results[:date] = date results[:day_of_week] = rule["validations"][:day_of_week]&.first&.day @@ -50,7 +48,6 @@ def get_values_from_reminder_schedule return if reminder_schedule.blank? results = from_ical(reminder_schedule) return if results.nil? - self.every_n_months = results[:every_n_months] self.date_or_week_day = results[:date_or_week_day] self.date = results[:date] self.day_of_week = results[:day_of_week] @@ -75,11 +72,10 @@ def reminder_is_within_range? def should_update_reminder_schedule if reminder_schedule.blank? - return every_n_months.present? + return date_or_week_day.present? end sched = from_ical(reminder_schedule) - every_n_months != sched[:every_n_months].presence.to_s || - date_or_week_day != sched[:date_or_week_day].presence.to_s || + date_or_week_day != sched[:date_or_week_day].presence.to_s || date != sched[:date].presence.to_s || day_of_week != sched[:day_of_week].presence.to_s || every_nth_day != sched[:every_nth_day].presence.to_s @@ -87,11 +83,13 @@ def should_update_reminder_schedule def create_schedule schedule = IceCube::Schedule.new(Time.zone.now.to_date) - return nil if every_n_months.blank? || every_n_months.to_i.zero? + return nil if date_or_week_day.blank? if date_or_week_day == "date" - schedule.add_recurrence_rule(IceCube::Rule.monthly(every_n_months.to_i).day_of_month(date.to_i)) + return nil if date.blank? + schedule.add_recurrence_rule(IceCube::Rule.monthly(1).day_of_month(date.to_i)) else - schedule.add_recurrence_rule(IceCube::Rule.monthly(every_n_months.to_i).day_of_week(day_of_week.to_i => [every_nth_day.to_i])) + return nil if day_of_week.blank? || every_nth_day.blank? + schedule.add_recurrence_rule(IceCube::Rule.monthly(1).day_of_week(day_of_week.to_i => [every_nth_day.to_i])) end schedule.to_ical rescue diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index 8a84f8c940..d81474f91f 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -5,14 +5,6 @@ next_month: Date::MONTHNAMES[Date.current.next_month.month] }) do %> - <%= f.input :every_n_months, - as: :integer, - label: 'Send reminders every X months', - class: "deadline-day-pickers__reminder-day form-control", - hint: "Enter the number of months between reminders", - :placeholder => "1", - :input_html => {:style=> 'width: 100px', :min => 0, :max => 12} %> - <%= f.label :date_or_week_day, 'Send reminders on a specific date (eg "the 5th") or a day of the week (eg "the first Tuesday")?' %>
<%= f.radio_button :date_or_week_day, 'date', label: 'Date', id: 'toggle-to-date' %> diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index aeb37e259d..869f9ae5db 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -33,35 +33,36 @@ def deadline_day? .allow_nil end - it 'validates the date field presence if date_or_week_day is "date"' do - dummy.every_n_months = 1 - dummy.date_or_week_day = "date" - is_expected.to validate_presence_of(:date) + it "validates the date_or_week_day field inclusion" do + is_expected.to validate_inclusion_of(:date_or_week_day).in_array(%w[date week_day]) end - it 'validate the day_of_week field presence if date_or_week_day is "week_day"' do - dummy.every_n_months = 1 - dummy.date_or_week_day = "week_day" - is_expected.to validate_presence_of(:day_of_week) - is_expected.to validate_presence_of(:every_nth_day) + it "validates the day of week field inclusion" do + dummy.day_of_week = "0" + expect(dummy).to be_valid + dummy.day_of_week = "A" + expect(dummy).not_to be_valid end it "validates the date_or_week_day field inclusion" do - dummy.every_n_months = 1 - is_expected.to validate_inclusion_of(:date_or_week_day).in_array(%w[date week_day]) + dummy.every_nth_day = "1" + expect(dummy).to be_valid + dummy.every_nth_day = "B" + expect(dummy).not_to be_valid end it "validates that the reminder schedule's date fall within the range" do - dummy.every_n_months = 1 dummy.date_or_week_day = "date" dummy.date = 29 expect(dummy).not_to be_valid expect(dummy.errors.added?(:date, "Reminder day must be between 1 and 28")).to be_truthy + + dummy.date = -1 + expect(dummy).not_to be_valid end it "validates that reminder day is not the same as deadline day" do - dummy.every_n_months = 1 dummy.date_or_week_day = "date" dummy.date = dummy.deadline_day diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 73d1225a1b..8749c9aa4e 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -381,10 +381,10 @@ describe 'reminder_schedule' do it "cannot exceed 28 if date_or_week_day is date" do - expect(build(:organization, every_n_months: 1, date_or_week_day: 'date', date: 28)).to be_valid - expect(build(:organization, every_n_months: 1, date_or_week_day: 'date', date: 29)).to_not be_valid - expect(build(:organization, every_n_months: 1, date_or_week_day: 'date', date: 0)).to_not be_valid - expect(build(:organization, every_n_months: 1, date_or_week_day: 'date', date: -5)).to_not be_valid + expect(build(:organization, date_or_week_day: 'date', date: 28)).to be_valid + expect(build(:organization, date_or_week_day: 'date', date: 29)).to_not be_valid + expect(build(:organization, date_or_week_day: 'date', date: 0)).to_not be_valid + expect(build(:organization, date_or_week_day: 'date', date: -5)).to_not be_valid end end describe 'deadline_day' do diff --git a/spec/models/partner_group_spec.rb b/spec/models/partner_group_spec.rb index 76a2061eb4..2a62000621 100644 --- a/spec/models/partner_group_spec.rb +++ b/spec/models/partner_group_spec.rb @@ -36,8 +36,8 @@ describe 'reminder_schedule day > 28 and <=0' do it 'raises error if unmet' do - expect { partner_group.update!(every_n_months: 1, date_or_week_day: 'date', date: 29) }.to raise_error(ActiveRecord::RecordInvalid) - expect { partner_group.update!(every_n_months: 1, date_or_week_day: 'date', date: -5) }.to raise_error(ActiveRecord::RecordInvalid) + expect { partner_group.update!(date_or_week_day: 'date', date: 29) }.to raise_error(ActiveRecord::RecordInvalid) + expect { partner_group.update!(date_or_week_day: 'date', date: -5) }.to raise_error(ActiveRecord::RecordInvalid) end end end diff --git a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb index 146322f9eb..a841016c8f 100644 --- a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb +++ b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb @@ -9,7 +9,7 @@ context "that has an organization with a global reminder & deadline" do context "that is for today" do before do - partner.organization.update(every_n_months: 1, date_or_week_day: "date", date: current_day, deadline_day: current_day + 2) + partner.organization.update(date_or_week_day: "date", date: current_day, deadline_day: current_day + 2) end it "should include that partner" do @@ -20,7 +20,7 @@ context "as matched by day of the week" do before do - partner.organization.update(every_n_months: 1, date_or_week_day: "week_day", + partner.organization.update(date_or_week_day: "week_day", day_of_week: 2, every_nth_day: 2, deadline_day: current_day + 2) end it "should include that partner" do @@ -53,7 +53,7 @@ context "that is not for today" do before do - partner.organization.update(every_n_months: 1, date_or_week_day: "date", + partner.organization.update(date_or_week_day: "date", date: current_day - 1, deadline_day: current_day + 2) end @@ -64,11 +64,11 @@ context "AND a partner group that does have them defined" do before do - partner_group = create(:partner_group, every_n_months: 1, date_or_week_day: "date", + partner_group = create(:partner_group, date_or_week_day: "date", date: current_day, deadline_day: current_day + 2) partner_group.partners << partner - partner.organization.update(every_n_months: 1, date_or_week_day: "date", + partner.organization.update(date_or_week_day: "date", date: current_day - 1, deadline_day: current_day + 2) end @@ -96,7 +96,7 @@ context "and is a part of a partner group that does have them defined" do context "that is for today" do before do - partner_group = create(:partner_group, every_n_months: 1, date_or_week_day: "date", + partner_group = create(:partner_group, date_or_week_day: "date", date: current_day, deadline_day: current_day + 2) partner_group.partners << partner end @@ -118,7 +118,7 @@ context "that is not for today" do before do - partner_group = create(:partner_group, every_n_months: 1, date_or_week_day: "date", + partner_group = create(:partner_group, date_or_week_day: "date", date: current_day - 1, deadline_day: current_day + 2) partner_group.partners << partner end diff --git a/spec/system/admin/organizations_system_spec.rb b/spec/system/admin/organizations_system_spec.rb index 887a740e0e..58f7338076 100644 --- a/spec/system/admin/organizations_system_spec.rb +++ b/spec/system/admin/organizations_system_spec.rb @@ -117,7 +117,6 @@ fill_in "organization_user_name", with: admin_user_params[:name] fill_in "organization_user_email", with: admin_user_params[:email] - fill_in "organization_every_n_months", with: 1 choose 'toggle-to-date' fill_in "organization_date", with: 1 diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 3632863f40..2321a88f95 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -630,7 +630,6 @@ # Opt in to sending deadline reminders check 'Yes' - fill_in "partner_group_every_n_months", with: 1, wait: page_content_wait choose 'toggle-to-date' fill_in "partner_group_date", with: 1 fill_in "partner_group_deadline_day", with: 25 @@ -679,14 +678,13 @@ click_on 'Edit' # Opt in to sending deadline reminders check 'Yes' - fill_in "partner_group_every_n_months", with: 2, wait: page_content_wait - choose 'toggle-to-week-day' + choose 'toggle-to-week-day', wait: page_content_wait select "Second", from: "partner_group_every_nth_day" select "Thursday", from: "partner_group_day_of_week" fill_in "partner_group_deadline_day", with: 24 find_button('Update Partner Group').click - assert page.has_content? 'Every 2 months on the 2nd Thursday', wait: page_content_wait + assert page.has_content? 'Monthly on the 2nd Thursday', wait: page_content_wait end end end From aeeebdb21216120a3a67333f3a358745b48361c0 Mon Sep 17 00:00:00 2001 From: Jesse Landis-Eigsti Date: Wed, 2 Oct 2024 10:04:30 -0600 Subject: [PATCH 09/94] 4473 - Updates names of Month vs Week fields Rather than "date" and "week_day", we are now using "day_of_month" and "day_of_week" for clarity. --- .../admin/organizations_controller.rb | 2 +- app/controllers/organizations_controller.rb | 2 +- app/controllers/partner_groups_controller.rb | 2 +- app/models/concerns/deadlinable.rb | 43 +++++++++---------- .../shared/_deadline_day_fields.html.erb | 16 +++---- spec/models/concerns/deadlinable_spec.rb | 20 ++++----- spec/models/organization_spec.rb | 10 ++--- spec/models/partner_group_spec.rb | 4 +- ...tch_partners_to_remind_now_service_spec.rb | 24 +++++------ .../system/admin/organizations_system_spec.rb | 2 +- spec/system/partner_system_spec.rb | 2 +- 11 files changed, 63 insertions(+), 64 deletions(-) diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb index 863de0396f..be2ed534b0 100644 --- a/app/controllers/admin/organizations_controller.rb +++ b/app/controllers/admin/organizations_controller.rb @@ -89,7 +89,7 @@ def destroy def organization_params params.require(:organization) .permit(:name, :short_name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_email_text, :account_request_id, - :date_or_week_day, :date, :day_of_week, :every_nth_day, :deadline_day, + :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :deadline_day, users_attributes: %i(name email organization_admin), account_request_attributes: %i(ndbn_member_id id)) end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index eb1be18e16..15a1e22fdb 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -101,7 +101,7 @@ def organization_params :enable_individual_requests, :enable_quantity_based_requests, :ytd_on_distribution_printout, :one_step_partner_invite, :hide_value_columns_on_receipt, :hide_package_column_on_receipt, - :signature_for_distribution_pdf, :date_or_week_day, :date, :day_of_week, + :signature_for_distribution_pdf, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, partner_form_fields: [], request_unit_names: [] diff --git a/app/controllers/partner_groups_controller.rb b/app/controllers/partner_groups_controller.rb index dccd70e8c3..cc00ffb5db 100644 --- a/app/controllers/partner_groups_controller.rb +++ b/app/controllers/partner_groups_controller.rb @@ -56,7 +56,7 @@ def set_partner_group def partner_group_params params.require(:partner_group).permit(:name, :send_reminders, :reminder_schedule, - :deadline_day, :date_or_week_day, :date, :day_of_week, :every_nth_day, item_category_ids: []) + :deadline_day, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, item_category_ids: []) end def set_items_categories diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index 2a9fd6d063..5d1529b01a 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -3,16 +3,15 @@ module Deadlinable MIN_DAY_OF_MONTH = 1 MAX_DAY_OF_MONTH = 28 EVERY_NTH_COLLECTION = [["First", 1], ["Second", 2], ["Third", 3], ["Fourth", 4], ["Last", -1]].freeze - WEEK_DAY_COLLECTION = [["Sunday", 0], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]].freeze + DAY_OF_WEEK_COLLECTION = [["Sunday", 0], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]].freeze included do - attr_accessor :date_or_week_day, :date, :day_of_week, :every_nth_day - attr_reader :every_nth_collection, :week_day_collection, :date_or_week_day_collection + attr_accessor :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day validates :deadline_day, numericality: {only_integer: true, less_than_or_equal_to: MAX_DAY_OF_MONTH, greater_than_or_equal_to: MIN_DAY_OF_MONTH, allow_nil: true} - validate :reminder_on_deadline_day?, if: -> { date.present? } - validate :reminder_is_within_range?, if: -> { date.present? } - validates :date_or_week_day, inclusion: {in: %w[date week_day]}, if: -> { date_or_week_day.present? } + validate :reminder_on_deadline_day?, if: -> { day_of_month.present? } + validate :reminder_is_within_range?, if: -> { day_of_month.present? } + validates :by_month_or_week, inclusion: {in: %w[day_of_month day_of_week]}, if: -> { by_month_or_week.present? } validates :day_of_week, if: -> { day_of_week.present? }, inclusion: {in: %w[0 1 2 3 4 5 6]} validates :every_nth_day, if: -> { every_nth_day.present? }, inclusion: {in: %w[1 2 3 4 -1]} end @@ -32,11 +31,11 @@ def from_ical(ical) return if ical.blank? schedule = IceCube::Schedule.from_ical(ical) rule = schedule.recurrence_rules.first.instance_values - date = rule["validations"][:day_of_month]&.first&.value + day_of_month = rule["validations"][:day_of_month]&.first&.value results = {} - results[:date_or_week_day] = date ? "date" : "week_day" - results[:date] = date + results[:by_month_or_week] = day_of_month ? "day_of_month" : "day_of_week" + results[:day_of_month] = day_of_month results[:day_of_week] = rule["validations"][:day_of_week]&.first&.day results[:every_nth_day] = rule["validations"][:day_of_week]&.first&.occ results @@ -48,8 +47,8 @@ def get_values_from_reminder_schedule return if reminder_schedule.blank? results = from_ical(reminder_schedule) return if results.nil? - self.date_or_week_day = results[:date_or_week_day] - self.date = results[:date] + self.by_month_or_week = results[:by_month_or_week] + self.day_of_month = results[:day_of_month] self.day_of_week = results[:day_of_week] self.every_nth_day = results[:every_nth_day] end @@ -57,36 +56,36 @@ def get_values_from_reminder_schedule private def reminder_on_deadline_day? - if date_or_week_day == "date" && date.to_i == deadline_day - errors.add(:date, "Reminder must not be the same as deadline date") + if by_month_or_week == "day_of_month" && day_of_month.to_i == deadline_day + errors.add(:day_of_month, "Reminder must not be the same as deadline date") end end def reminder_is_within_range? # IceCube converts negative or zero days to valid days (e.g. -1 becomes the last day of the month, 0 becomes 1) # The minimum check should no longer be necessary, but keeping it in case IceCube changes - if date_or_week_day == "date" && date.to_i < MIN_DAY_OF_MONTH || date.to_i > MAX_DAY_OF_MONTH - errors.add(:date, "Reminder day must be between #{MIN_DAY_OF_MONTH} and #{MAX_DAY_OF_MONTH}") + if by_month_or_week == "day_of_month" && day_of_month.to_i < MIN_DAY_OF_MONTH || day_of_month.to_i > MAX_DAY_OF_MONTH + errors.add(:day_of_month, "Reminder day must be between #{MIN_DAY_OF_MONTH} and #{MAX_DAY_OF_MONTH}") end end def should_update_reminder_schedule if reminder_schedule.blank? - return date_or_week_day.present? + return by_month_or_week.present? end sched = from_ical(reminder_schedule) - date_or_week_day != sched[:date_or_week_day].presence.to_s || - date != sched[:date].presence.to_s || + by_month_or_week != sched[:by_month_or_week].presence.to_s || + day_of_month != sched[:day_of_month].presence.to_s || day_of_week != sched[:day_of_week].presence.to_s || every_nth_day != sched[:every_nth_day].presence.to_s end def create_schedule schedule = IceCube::Schedule.new(Time.zone.now.to_date) - return nil if date_or_week_day.blank? - if date_or_week_day == "date" - return nil if date.blank? - schedule.add_recurrence_rule(IceCube::Rule.monthly(1).day_of_month(date.to_i)) + return nil if by_month_or_week.blank? + if by_month_or_week == "day_of_month" + return nil if day_of_month.blank? + schedule.add_recurrence_rule(IceCube::Rule.monthly(1).day_of_month(day_of_month.to_i)) else return nil if day_of_week.blank? || every_nth_day.blank? schedule.add_recurrence_rule(IceCube::Rule.monthly(1).day_of_week(day_of_week.to_i => [every_nth_day.to_i])) diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index d81474f91f..9f2d27ae08 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -5,19 +5,19 @@ next_month: Date::MONTHNAMES[Date.current.next_month.month] }) do %> - <%= f.label :date_or_week_day, 'Send reminders on a specific date (eg "the 5th") or a day of the week (eg "the first Tuesday")?' %> + <%= f.label :by_month_or_week, 'Send reminders on a specific day of the month (e.g. "the 5th") or a day of the week (eg "the first Tuesday")?' %>
- <%= f.radio_button :date_or_week_day, 'date', label: 'Date', id: 'toggle-to-date' %> - <%= f.label :date_or_week_day, 'Date' %> + <%= f.radio_button :by_month_or_week, 'day_of_month', label: 'Day of Month', id: 'toggle-to-date' %> + <%= f.label :by_month_or_week, 'Day of Month' %>
- <%= f.radio_button :date_or_week_day, 'week_day', label: 'Week Day', id: 'toggle-to-week-day' %> - <%= f.label :date_or_week_day, 'Week Day' %> + <%= f.radio_button :by_month_or_week, 'day_of_week', label: 'Day of the Week', id: 'toggle-to-week-day' %> + <%= f.label :by_month_or_week, 'Day of the Week' %>
- <%= f.input :date, as: :integer, wrapper: :input_group, + <%= f.input :day_of_month, as: :integer, wrapper: :input_group, label: 'Reminder date' do %> - <%= f.number_field :date, + <%= f.number_field :day_of_month, min: Deadlinable::MIN_DAY_OF_MONTH, max: Deadlinable::MAX_DAY_OF_MONTH, class: "deadline-day-pickers__reminder-day form-control", @@ -33,7 +33,7 @@ class: "deadline-day-pickers__reminder-day form-control", label: false %> - <%= f.input :day_of_week, collection: Deadlinable::WEEK_DAY_COLLECTION, + <%= f.input :day_of_week, collection: Deadlinable::DAY_OF_WEEK_COLLECTION, class: "deadline-day-pickers__reminder-day form-control", label: false, show_blank: true, diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index 869f9ae5db..4d56993c2f 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -33,8 +33,8 @@ def deadline_day? .allow_nil end - it "validates the date_or_week_day field inclusion" do - is_expected.to validate_inclusion_of(:date_or_week_day).in_array(%w[date week_day]) + it "validates the by_month_or_week field inclusion" do + is_expected.to validate_inclusion_of(:by_month_or_week).in_array(%w[day_of_month day_of_week]) end it "validates the day of week field inclusion" do @@ -44,7 +44,7 @@ def deadline_day? expect(dummy).not_to be_valid end - it "validates the date_or_week_day field inclusion" do + it "validates the by_month_or_week field inclusion" do dummy.every_nth_day = "1" expect(dummy).to be_valid dummy.every_nth_day = "B" @@ -52,22 +52,22 @@ def deadline_day? end it "validates that the reminder schedule's date fall within the range" do - dummy.date_or_week_day = "date" - dummy.date = 29 + dummy.by_month_or_week = "day_of_month" + dummy.day_of_month = 29 expect(dummy).not_to be_valid - expect(dummy.errors.added?(:date, "Reminder day must be between 1 and 28")).to be_truthy + expect(dummy.errors.added?(:day_of_month, "Reminder day must be between 1 and 28")).to be_truthy - dummy.date = -1 + dummy.day_of_month = -1 expect(dummy).not_to be_valid end it "validates that reminder day is not the same as deadline day" do - dummy.date_or_week_day = "date" - dummy.date = dummy.deadline_day + dummy.by_month_or_week = "day_of_month" + dummy.day_of_month = dummy.deadline_day expect(dummy).not_to be_valid - expect(dummy.errors.added?(:date, "Reminder must not be the same as deadline date")).to be_truthy + expect(dummy.errors.added?(:day_of_month, "Reminder must not be the same as deadline date")).to be_truthy end end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 8749c9aa4e..548a3aba2c 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -380,11 +380,11 @@ end describe 'reminder_schedule' do - it "cannot exceed 28 if date_or_week_day is date" do - expect(build(:organization, date_or_week_day: 'date', date: 28)).to be_valid - expect(build(:organization, date_or_week_day: 'date', date: 29)).to_not be_valid - expect(build(:organization, date_or_week_day: 'date', date: 0)).to_not be_valid - expect(build(:organization, date_or_week_day: 'date', date: -5)).to_not be_valid + it "cannot exceed 28 if by_month_or_week is day_of_month" do + expect(build(:organization, by_month_or_week: 'day_of_month', day_of_month: 28)).to be_valid + expect(build(:organization, by_month_or_week: 'day_of_month', day_of_month: 29)).to_not be_valid + expect(build(:organization, by_month_or_week: 'day_of_month', day_of_month: 0)).to_not be_valid + expect(build(:organization, by_month_or_week: 'day_of_month', day_of_month: -5)).to_not be_valid end end describe 'deadline_day' do diff --git a/spec/models/partner_group_spec.rb b/spec/models/partner_group_spec.rb index 2a62000621..493e3b5520 100644 --- a/spec/models/partner_group_spec.rb +++ b/spec/models/partner_group_spec.rb @@ -36,8 +36,8 @@ describe 'reminder_schedule day > 28 and <=0' do it 'raises error if unmet' do - expect { partner_group.update!(date_or_week_day: 'date', date: 29) }.to raise_error(ActiveRecord::RecordInvalid) - expect { partner_group.update!(date_or_week_day: 'date', date: -5) }.to raise_error(ActiveRecord::RecordInvalid) + expect { partner_group.update!(by_month_or_week: 'day_of_month', day_of_month: 29) }.to raise_error(ActiveRecord::RecordInvalid) + expect { partner_group.update!(by_month_or_week: 'day_of_month', day_of_month: -5) }.to raise_error(ActiveRecord::RecordInvalid) end end end diff --git a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb index a841016c8f..f87a271246 100644 --- a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb +++ b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb @@ -9,7 +9,7 @@ context "that has an organization with a global reminder & deadline" do context "that is for today" do before do - partner.organization.update(date_or_week_day: "date", date: current_day, deadline_day: current_day + 2) + partner.organization.update(by_month_or_week: "day_of_month", day_of_month: current_day, deadline_day: current_day + 2) end it "should include that partner" do @@ -20,7 +20,7 @@ context "as matched by day of the week" do before do - partner.organization.update(date_or_week_day: "week_day", + partner.organization.update(by_month_or_week: "day_of_week", day_of_week: 2, every_nth_day: 2, deadline_day: current_day + 2) end it "should include that partner" do @@ -53,8 +53,8 @@ context "that is not for today" do before do - partner.organization.update(date_or_week_day: "date", - date: current_day - 1, deadline_day: current_day + 2) + partner.organization.update(by_month_or_week: "day_of_month", + day_of_month: current_day - 1, deadline_day: current_day + 2) end it "should NOT include that partner" do @@ -64,12 +64,12 @@ context "AND a partner group that does have them defined" do before do - partner_group = create(:partner_group, date_or_week_day: "date", - date: current_day, deadline_day: current_day + 2) + partner_group = create(:partner_group, by_month_or_week: "day_of_month", + day_of_month: current_day, deadline_day: current_day + 2) partner_group.partners << partner - partner.organization.update(date_or_week_day: "date", - date: current_day - 1, deadline_day: current_day + 2) + partner.organization.update(by_month_or_week: "day_of_month", + day_of_month: current_day - 1, deadline_day: current_day + 2) end it "should remind based on the partner group instead of the organization level reminder" do @@ -96,8 +96,8 @@ context "and is a part of a partner group that does have them defined" do context "that is for today" do before do - partner_group = create(:partner_group, date_or_week_day: "date", - date: current_day, deadline_day: current_day + 2) + partner_group = create(:partner_group, by_month_or_week: "day_of_month", + day_of_month: current_day, deadline_day: current_day + 2) partner_group.partners << partner end @@ -118,8 +118,8 @@ context "that is not for today" do before do - partner_group = create(:partner_group, date_or_week_day: "date", - date: current_day - 1, deadline_day: current_day + 2) + partner_group = create(:partner_group, by_month_or_week: "day_of_month", + day_of_month: current_day - 1, deadline_day: current_day + 2) partner_group.partners << partner end diff --git a/spec/system/admin/organizations_system_spec.rb b/spec/system/admin/organizations_system_spec.rb index 58f7338076..c4c70e4b81 100644 --- a/spec/system/admin/organizations_system_spec.rb +++ b/spec/system/admin/organizations_system_spec.rb @@ -118,7 +118,7 @@ fill_in "organization_user_email", with: admin_user_params[:email] choose 'toggle-to-date' - fill_in "organization_date", with: 1 + fill_in "organization_day_of_month", with: 1 click_on "Save" end diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 2321a88f95..75c8a1df36 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -631,7 +631,7 @@ check 'Yes' choose 'toggle-to-date' - fill_in "partner_group_date", with: 1 + fill_in "partner_group_day_of_month", with: 1 fill_in "partner_group_deadline_day", with: 25 find_button('Add Partner Group').click From 7352100f4b241c850a90a27d2c0d748a6fe6793f Mon Sep 17 00:00:00 2001 From: Jesse Landis-Eigsti Date: Fri, 4 Oct 2024 14:23:16 -0600 Subject: [PATCH 10/94] 4473 - fixes reminder day vs deadline display bug There was a bug in which if the reminder day of the month was the same as the deadline day, it would dislplay the error message even if the user changed to the day of the week option. Now the error message is hidden if the user is selecting the day of the week option. --- app/javascript/utils/deadline_day_pickers.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/javascript/utils/deadline_day_pickers.js b/app/javascript/utils/deadline_day_pickers.js index 31a86dc459..1ab5082ec2 100644 --- a/app/javascript/utils/deadline_day_pickers.js +++ b/app/javascript/utils/deadline_day_pickers.js @@ -6,6 +6,7 @@ $(document).ready(function () { const deadline_selector = '.deadline-day-pickers__deadline-day'; const reminder_container_selector = '.deadline-day-pickers__reminder-container'; const deadline_container_selector = '.deadline-day-pickers__deadline-container'; + const day_of_week_toggle_selector = '#toggle-to-week-day'; const reminder_text_selector = '.deadline-day-pickers__reminder-day-text'; const deadline_text_selector = '.deadline-day-pickers__deadline-day-text'; @@ -18,6 +19,7 @@ $(document).ready(function () { const $deadline = $container.find(deadline_selector); const $reminder_text = $container.find(reminder_text_selector); const $deadline_text = $container.find(deadline_text_selector); + const $day_of_week_toggle = $container.find(day_of_week_toggle_selector)[0]; const reminder_day = parseInt($reminder.val()); const deadline_day = parseInt($deadline.val()); @@ -28,17 +30,21 @@ $(document).ready(function () { if (reminder_day) { $(container).find(reminder_container_selector).find(server_validation_selector).remove(); - if (reminder_day === deadline_day) { + + if (reminder_day === deadline_day && !$day_of_week_toggle.checked) { $reminder_text.removeClass('text-muted').addClass('text-danger'); $reminder_text.text('Reminder day cannot be the same as deadline day.'); - } else { + } + else if ($day_of_week_toggle.checked){ + $reminder_text.text(''); + } + else { $reminder_text.removeClass('text-danger').addClass('text-muted'); - - const next_reminder_month = (current_day >= reminder_day) ? next_month : current_month; - $reminder_text.text(`Your next reminder will be sent on ${reminder_day} ${next_reminder_month}.`); + const next_reminder_month = (current_day >= reminder_day) ? next_month : current_month; + $reminder_text.text(`Your next reminder will be sent on ${reminder_day} ${next_reminder_month}.`); + } } - } if (deadline_day) { $(container).find(deadline_container_selector).find(server_validation_selector).remove(); From c9a6509e7758b36402b36cacde5531aece0f6064 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Tue, 8 Apr 2025 08:43:00 -0600 Subject: [PATCH 11/94] Changes made by linter --- app/models/partner_group.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index acd8563ed6..a4998e0076 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -23,7 +23,6 @@ class PartnerGroup < ApplicationRecord self.reminder_schedule = create_schedule end - validates :organization, presence: true validates :name, presence: true, uniqueness: { scope: :organization } validates :deadline_day, presence: true, if: :send_reminders? end From 7bc7a85d16df947f1d076bcacb85dea46b10a124 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 9 Apr 2025 11:52:11 -0600 Subject: [PATCH 12/94] Removed references to factored out ReminderSchedule model --- app/controllers/admin/organizations_controller.rb | 2 -- app/views/organizations/_details.html.erb | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb index be2ed534b0..3f7a9061c6 100644 --- a/app/controllers/admin/organizations_controller.rb +++ b/app/controllers/admin/organizations_controller.rb @@ -47,8 +47,6 @@ def new def create @user = User.new(user_params) - - @reminder_schedule = ReminderSchedule.new(reminder_schedule_params) @organization = Organization.new(organization_params) if @organization.save Organization.seed_items(@organization) diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb index 76d1d7bdc8..856e3fd1af 100644 --- a/app/views/organizations/_details.html.erb +++ b/app/views/organizations/_details.html.erb @@ -199,7 +199,7 @@

Reminder Schedule

<%= fa_icon "calendar" %> - <%= @organization.reminder_schedule.blank? ? 'Not defined' : ReminderSchedule.show_description(@organization.reminder_schedule) %> + <%= @organization.reminder_schedule.blank? ? 'Not defined' : @organization.show_description(@organization.reminder_schedule) %>

From d4330bfcfd26c73739a4ac2f13966fde15bddf1f Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 10 Apr 2025 07:23:00 -0600 Subject: [PATCH 13/94] Removed reference to factored out reminder_day --- app/views/organizations/_details.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb index 856e3fd1af..895a4ca554 100644 --- a/app/views/organizations/_details.html.erb +++ b/app/views/organizations/_details.html.erb @@ -448,7 +448,7 @@
Default reminder day (day of month an email reminder to submit Requests is sent to Partners)

<%= fa_icon "calendar" %> - <%= @organization.reminder_day.blank? ? 'Not defined' : "The #{@organization.reminder_day.ordinalize} of each month" %> + <%= @organization.reminder_schedule.blank? ? 'Not defined' : @organization.show_description(@organization.reminder_schedule) %>

From 8b110f2e2b7d9aeaa5916642c3fcd09dda837479 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 10 Apr 2025 08:27:28 -0600 Subject: [PATCH 14/94] Removed redundant/outdated additions to the organizations detail page --- app/views/organizations/_details.html.erb | 268 ---------------------- 1 file changed, 268 deletions(-) diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb index 895a4ca554..a37f95291d 100644 --- a/app/views/organizations/_details.html.erb +++ b/app/views/organizations/_details.html.erb @@ -61,274 +61,6 @@ <% end %>

-
-

Reminder Schedule

-

- <%= fa_icon "calendar" %> - <%= @organization.reminder_schedule.blank? ? 'Not defined' : @organization.show_description(@organization.reminder_schedule) %> -

-
-
-

Deadline day

-

- <%= fa_icon "calendar" %> - <%= @organization.deadline_day.blank? ? 'Not defined' : "The #{@organization.deadline_day.ordinalize} of each month" %> -

-
-
-

Default Intake Location

-

- <%= fa_icon "building" %> - <%= StorageLocation.find_by(id: @organization.intake_location)&.name || "Not defined" %> -

-
-
-

Partner Profile Sections

- <% if @organization.partner_form_fields.blank? %> -

Not Provided

- <% else %> -
    - <% for partner_form_field in @organization.partner_form_fields %> -
  • - <%= display_partner_fields_value(partner_form_field) %> -
  • - <% end %> -
- <% end %> -

-
-
-

Default Storage Location

-

- <%= fa_icon "building-o" %> - <%= StorageLocation.find_by(id: @organization.default_storage_location)&.name || "Not defined" %> -

-
-
-

Custom Partner Invitation Message

-

- <%= @organization.invitation_text.blank? ? "Not defined" : @organization.invitation_text %> -

-
-
-

Repackage Essentials?

-

- <%= fa_icon "inbox" %> - <%= humanize_boolean(@organization.repackage_essentials) %> -

-
-
-

Distribute Monthly?

-

- <%= fa_icon "paper-plane" %> - <%= humanize_boolean(@organization.distribute_monthly) %> -

-
-
-

Child Based Requests?

-

- <%= fa_icon "child" %> - <%= humanize_boolean(@organization.enable_child_based_requests) %> -

-
-
-

Individual Requests?

-

- <%= fa_icon "female" %> - <%= humanize_boolean(@organization.enable_individual_requests) %> -

-
-
-

Quantity Based Requests?

-

- <%= fa_icon "group" %> - <%= humanize_boolean(@organization.enable_quantity_based_requests) %> -

-
-
-

Show Year-to-date values on distribution printout?

-

- <%= humanize_boolean(@organization.ytd_on_distribution_printout) %> -

-
-
-

Include Signature Lines on Distribution Printout?

-

- <%= humanize_boolean(@organization.signature_for_distribution_pdf) %> -

-
-
-

Use One step Partner invite and approve process?

-

- <%= humanize_boolean(@organization.one_step_partner_invite) %> -

-
-
-

Hide value columns on receipt:

-

- <%= humanize_boolean(@organization.hide_value_columns_on_receipt) %> -

-
-
-

Hide package column on receipt:

-

- <%= humanize_boolean(@organization.hide_package_column_on_receipt) %> -

-
- <% if @organization.logo.attached? %> -
-

Logo

-

- <%= image_tag @organization.logo, class: "main_logo" %> -

-

- - View Original - -

- -
- <% end %> -
-
-

Reminder Schedule

-

- <%= fa_icon "calendar" %> - <%= @organization.reminder_schedule.blank? ? 'Not defined' : @organization.show_description(@organization.reminder_schedule) %> -

-
-
-

Deadline day

-

- <%= fa_icon "calendar" %> - <%= @organization.deadline_day.blank? ? 'Not defined' : "The #{@organization.deadline_day.ordinalize} of each month" %> -

-
-
-

Default Intake Location

-

- <%= fa_icon "building" %> - <%= StorageLocation.find_by(id: @organization.intake_location)&.name || "Not defined" %> -

-
-
-

Partner Profile Sections

- <% if @organization.partner_form_fields.blank? %> -

Not Provided

- <% else %> -
    - <% for partner_form_field in @organization.partner_form_fields %> -
  • - <%= display_partner_fields_value(partner_form_field) %> -
  • - <% end %> -
- <% end %> -

-
-
-

Default Storage Location

-

- <%= fa_icon "building-o" %> - <%= StorageLocation.find_by(id: @organization.default_storage_location)&.name || "Not defined" %> -

-
-
-

Custom Partner Invitation Message

-

- <%= @organization.invitation_text.blank? ? "Not defined" : @organization.invitation_text %> -

-
-
-

Repackage Essentials?

-

- <%= fa_icon "inbox" %> - <%= humanize_boolean(@organization.repackage_essentials) %> -

-
-
-

Distribute Monthly?

-

- <%= fa_icon "paper-plane" %> - <%= humanize_boolean(@organization.distribute_monthly) %> -

-
-
-

Child Based Requests?

-

- <%= fa_icon "child" %> - <%= humanize_boolean(@organization.enable_child_based_requests) %> -

-
-
-

Individual Requests?

-

- <%= fa_icon "female" %> - <%= humanize_boolean(@organization.enable_individual_requests) %> -

-
-
-

Quantity Based Requests?

-

- <%= fa_icon "group" %> - <%= humanize_boolean(@organization.enable_quantity_based_requests) %> -

-
-
-

Show Year-to-date values on distribution printout?

-

- <%= humanize_boolean(@organization.ytd_on_distribution_printout) %> -

-
-
-

Include Signature Lines on Distribution Printout?

-

- <%= humanize_boolean(@organization.signature_for_distribution_pdf) %> -

-
-
-

Use One step Partner invite and approve process?

-

- <%= humanize_boolean(@organization.one_step_partner_invite) %> -

-
-
-

Hide value columns on receipt:

-

- <%= humanize_boolean(@organization.hide_value_columns_on_receipt) %> -

-
-
-

Hide package column on receipt:

-

- <%= humanize_boolean(@organization.hide_package_column_on_receipt) %> -

-
- <% if @organization.logo.attached? %> -
-

Logo

-

- <%= image_tag @organization.logo, class: "main_logo" %> -

-

- - View Original - -

- -
- <% end %> -
Logo
<% if @organization.logo.attached? %> From a9fdf9977f8b1520f6a5c689768384f8d17fec5b Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 10 Apr 2025 08:53:11 -0600 Subject: [PATCH 15/94] Fixed accidentally removing receive_email_on_requests from organization_params during merge --- app/controllers/organizations_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 15a1e22fdb..4e76c94803 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -101,8 +101,8 @@ def organization_params :enable_individual_requests, :enable_quantity_based_requests, :ytd_on_distribution_printout, :one_step_partner_invite, :hide_value_columns_on_receipt, :hide_package_column_on_receipt, - :signature_for_distribution_pdf, :by_month_or_week, :day_of_month, :day_of_week, - :every_nth_day, + :signature_for_distribution_pdf, :receive_email_on_requests, + :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, partner_form_fields: [], request_unit_names: [] ) From 8c1cde740c61b81f6c6f99019ff9f6e46f132886 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Fri, 11 Apr 2025 08:21:02 -0600 Subject: [PATCH 16/94] Updated organization system specs to reflect current organization details page and verify new reminder schedule interface --- spec/system/organization_system_spec.rb | 64 ++++++++++++++----------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index c6af749666..3351fa19ed 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -3,6 +3,8 @@ let(:user) { create(:user, organization: organization) } let(:organization_admin) { create(:organization_admin, organization: organization) } let(:super_admin_org_admin) { create(:super_admin_org_admin, organization: organization) } + let!(:storage_location) { create(:storage_location, :with_items, organization: organization) } + let!(:ndbn_member) { create(:ndbn_member) } include ActionView::RecordIdentifier @@ -57,20 +59,13 @@ expect(page.find("h1")).to have_text(organization.name) expect(page).to have_link("Home", href: dashboard_path) - expect(page).to have_content("Organization Info") - expect(page).to have_content("Contact Info") - expect(page).to have_content("Default email text") - expect(page).to have_content("Users") - expect(page).to have_content("Short Name") - expect(page).to have_content("URL") - expect(page).to have_content("Partner Profile Sections") - expect(page).to have_content("Custom Partner Invitation Message") - expect(page).to have_content("Child Based Requests?") - expect(page).to have_content("Individual Requests?") - expect(page).to have_content("Quantity Based Requests?") - expect(page).to have_content("Show Year-to-date values on distribution printout?") - expect(page).to have_content("Logo") - expect(page).to have_content("Use One step Partner invite and approve process?") + expect(page).to have_content("Basic information") + expect(page).to have_content("Storage") + expect(page).to have_content("Partner approval process") + expect(page).to have_content("What kind of Requests can approved Partners make?") + expect(page).to have_content("Other emails") + expect(page).to have_content("Printing") + expect(page).to have_content("Annual Survey") end end @@ -80,25 +75,37 @@ end it "is prompted with placeholder text and a more helpful error message to ensure correct URL format as a user" do - fill_in "Url", with: "www.diaperbase.com" + fill_in "URL", with: "notavalidemail" click_on "Save" + expect(page.find(".alert")).to have_content "Url it should look like 'http://www.example.com'" - fill_in "Url", with: "http://www.diaperbase.com" + fill_in "URL", with: "http://www.diaperbase.com" click_on "Save" - expect(page.find(".alert")).to have_content "pdated" + expect(page.find(".alert")).to have_content "Updated" end - it "can set a reminder and a deadline day" do - # TODO: change here - fill_in "organization_every_n_months", with: 1 - choose 'toggle-to-week-day' - select "First", from: "organization_every_nth_day" - select "Friday", from: "organization_day_of_week" + it "can set a reminder on a day of the month" do + choose "toggle-to-date" + fill_in "organization_day_of_month", with: 1 + click_on "Save" + expect(page.find(".alert")).to have_content "Updated your organization!" + expect(page).to have_content("Monthly on the 1st day of the month") + end - fill_in "organization_deadline_day", with: 16 + it "can set a reminder on a day of the week" do + choose "toggle-to-week-day" + select("First", from: "organization_every_nth_day" ) + select("Sunday", from: "organization_day_of_week" ) click_on "Save" - expect(page.find(".alert")).to have_content "Updated" - expect(page).to have_content("Monthly on the 1st Friday") + expect(page.find(".alert")).to have_content "Updated your organization!" + expect(page).to have_content("Monthly on the 1st Sunday") + end + + it "can set a default deadline day" do + fill_in "Default deadline day (final day of month to submit Requests)", with: 20 + click_on "Save" + expect(page.find(".alert")).to have_content "Updated your organization!" + expect(page).to have_content("The 20th of each month") end it 'can select if the org repackages essentials' do @@ -123,13 +130,14 @@ end it 'can set a default storage location on the organization' do - select(store.name, from: 'Default Storage Location') + select(storage_location.name, from: 'Default Storage Location') click_on "Save" - expect(page).to have_content(store.name) + expect(page).to have_content(storage_location.name) end it 'can set the NDBN Member ID' do + expect(page).to have_content('NDBN membership ID') select(ndbn_member.full_name) click_on "Save" From 74eefed7df810f470223a008df06abe120297c1b Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sun, 13 Apr 2025 08:53:35 -0600 Subject: [PATCH 17/94] Added every_nth_month field to let users specify a monthly frequency for reminders --- .../admin/organizations_controller.rb | 2 +- app/controllers/organizations_controller.rb | 1 + app/controllers/partner_groups_controller.rb | 2 +- app/models/concerns/deadlinable.rb | 17 ++++++++++++----- app/views/shared/_deadline_day_fields.html.erb | 11 +++++++++++ 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb index 3f7a9061c6..50068688a5 100644 --- a/app/controllers/admin/organizations_controller.rb +++ b/app/controllers/admin/organizations_controller.rb @@ -87,7 +87,7 @@ def destroy def organization_params params.require(:organization) .permit(:name, :short_name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_email_text, :account_request_id, - :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :deadline_day, + :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month, :deadline_day, users_attributes: %i(name email organization_admin), account_request_attributes: %i(ndbn_member_id id)) end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 4e76c94803..00b8e24b13 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -103,6 +103,7 @@ def organization_params :hide_value_columns_on_receipt, :hide_package_column_on_receipt, :signature_for_distribution_pdf, :receive_email_on_requests, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, + :every_nth_month, partner_form_fields: [], request_unit_names: [] ) diff --git a/app/controllers/partner_groups_controller.rb b/app/controllers/partner_groups_controller.rb index cc00ffb5db..9085dbdee3 100644 --- a/app/controllers/partner_groups_controller.rb +++ b/app/controllers/partner_groups_controller.rb @@ -56,7 +56,7 @@ def set_partner_group def partner_group_params params.require(:partner_group).permit(:name, :send_reminders, :reminder_schedule, - :deadline_day, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, item_category_ids: []) + :deadline_day, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month, item_category_ids: []) end def set_items_categories diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index 5d1529b01a..d6d53c8c39 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -4,9 +4,12 @@ module Deadlinable MAX_DAY_OF_MONTH = 28 EVERY_NTH_COLLECTION = [["First", 1], ["Second", 2], ["Third", 3], ["Fourth", 4], ["Last", -1]].freeze DAY_OF_WEEK_COLLECTION = [["Sunday", 0], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]].freeze + EVERY_NTH_MONTH_COLLECTION = [["Monthly", 1], ["Every 2 months", 2], ["Every 3 months", 3], ["Every 4 months", 4], ["Every 5 months", 5], + ["Every 6 months", 6], ["Every 7 months", 7], ["Every 8 months", 8], ["Every 9 months", 9], ["Every 10 months", 10], ["Every 11 months", 11], + ["Every 12 months", 12],].freeze included do - attr_accessor :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day + attr_accessor :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month validates :deadline_day, numericality: {only_integer: true, less_than_or_equal_to: MAX_DAY_OF_MONTH, greater_than_or_equal_to: MIN_DAY_OF_MONTH, allow_nil: true} validate :reminder_on_deadline_day?, if: -> { day_of_month.present? } @@ -14,6 +17,7 @@ module Deadlinable validates :by_month_or_week, inclusion: {in: %w[day_of_month day_of_week]}, if: -> { by_month_or_week.present? } validates :day_of_week, if: -> { day_of_week.present? }, inclusion: {in: %w[0 1 2 3 4 5 6]} validates :every_nth_day, if: -> { every_nth_day.present? }, inclusion: {in: %w[1 2 3 4 -1]} + validates :every_nth_month, if: -> { every_nth_month.present? }, inclusion: {in: EVERY_NTH_MONTH_COLLECTION.map{|ar| ar[1].to_s} } end def convert_to_reminder_schedule(day) @@ -38,6 +42,7 @@ def from_ical(ical) results[:day_of_month] = day_of_month results[:day_of_week] = rule["validations"][:day_of_week]&.first&.day results[:every_nth_day] = rule["validations"][:day_of_week]&.first&.occ + results[:every_nth_month] = rule["validations"][:interval]&.first&.interval results rescue nil @@ -51,6 +56,7 @@ def get_values_from_reminder_schedule self.day_of_month = results[:day_of_month] self.day_of_week = results[:day_of_week] self.every_nth_day = results[:every_nth_day] + self.every_nth_month = results[:every_nth_month] end private @@ -77,18 +83,19 @@ def should_update_reminder_schedule by_month_or_week != sched[:by_month_or_week].presence.to_s || day_of_month != sched[:day_of_month].presence.to_s || day_of_week != sched[:day_of_week].presence.to_s || - every_nth_day != sched[:every_nth_day].presence.to_s + every_nth_day != sched[:every_nth_day].presence.to_s || + every_nth_month != sched[:every_nth_month].presence.to_s end def create_schedule schedule = IceCube::Schedule.new(Time.zone.now.to_date) - return nil if by_month_or_week.blank? + return nil if by_month_or_week.blank? || every_nth_month.blank? if by_month_or_week == "day_of_month" return nil if day_of_month.blank? - schedule.add_recurrence_rule(IceCube::Rule.monthly(1).day_of_month(day_of_month.to_i)) + schedule.add_recurrence_rule(IceCube::Rule.monthly(every_nth_month).day_of_month(day_of_month.to_i)) else return nil if day_of_week.blank? || every_nth_day.blank? - schedule.add_recurrence_rule(IceCube::Rule.monthly(1).day_of_week(day_of_week.to_i => [every_nth_day.to_i])) + schedule.add_recurrence_rule(IceCube::Rule.monthly(every_nth_month).day_of_week(day_of_week.to_i => [every_nth_day.to_i])) end schedule.to_ical rescue diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index 9f2d27ae08..772f65c0ec 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -5,6 +5,17 @@ next_month: Date::MONTHNAMES[Date.current.next_month.month] }) do %> + <%= f.label :every_nth_month, 'How frequently should reminders be sent (e.g. "monthly", "every 3 months", etc.)?' %> + + <%= f.input :every_nth_month, + collection: Deadlinable::EVERY_NTH_MONTH_COLLECTION, + class: "deadline-day-pickers__reminder-day form-control", + label: false, + show_blank: true, + default: 1, + :input_html => {:style=> 'width: 200px'} + %> + <%= f.label :by_month_or_week, 'Send reminders on a specific day of the month (e.g. "the 5th") or a day of the week (eg "the first Tuesday")?' %>
<%= f.radio_button :by_month_or_week, 'day_of_month', label: 'Day of Month', id: 'toggle-to-date' %> From 559788810531fd45ab177ad1ef7ac3d0531f820f Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 30 Apr 2025 09:59:46 -0600 Subject: [PATCH 18/94] Cleaned up _deadline_day_fields.html.erb and reworked it to hook up to new stimulus controller, replaced conditional SASS rule with JS implementation for sake of readability/maintainability --- app/assets/stylesheets/custom.scss | 11 -- app/javascript/application.js | 1 - .../controllers/deadline_day_controller.js | 44 +++++++ app/javascript/utils/deadline_day_pickers.js | 79 ------------ .../shared/_deadline_day_fields.html.erb | 119 +++++++++--------- 5 files changed, 104 insertions(+), 150 deletions(-) create mode 100644 app/javascript/controllers/deadline_day_controller.js delete mode 100644 app/javascript/utils/deadline_day_pickers.js diff --git a/app/assets/stylesheets/custom.scss b/app/assets/stylesheets/custom.scss index fdf417ced1..4c349ab669 100644 --- a/app/assets/stylesheets/custom.scss +++ b/app/assets/stylesheets/custom.scss @@ -112,14 +112,3 @@ max-width: 100%; } } -#week-day-fields, #date-fields { - display: none; -} - -#toggle-to-week-day:checked ~ #week-day-fields { - display: block; -} - -#toggle-to-date:checked ~ #date-fields { - display: block -} diff --git a/app/javascript/application.js b/app/javascript/application.js index 8f2ff3a980..e702d41ccf 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -28,7 +28,6 @@ import 'controllers' import 'utils/barcode_items' import 'utils/barcode_scan' -import 'utils/deadline_day_pickers' import 'utils/distributions_and_transfers' import 'utils/donations' import 'utils/purchases' diff --git a/app/javascript/controllers/deadline_day_controller.js b/app/javascript/controllers/deadline_day_controller.js new file mode 100644 index 0000000000..6c61abe4b2 --- /dev/null +++ b/app/javascript/controllers/deadline_day_controller.js @@ -0,0 +1,44 @@ +import { Controller } from "@hotwired/stimulus" +import $ from 'jquery'; + +export default class extends Controller { + static targets = [ + 'everyNthMonth', 'byDayOfMonth', 'byDayOfWeek', 'dayOfMonthFields', 'dayOfMonth', + 'dayOfWeekFields', 'everyNthDay', 'dayOfWeek', 'deadlineDay', 'reminderText', 'deadlineText' + ] + + sourceChange() { + let reminder_day = null; + let deadline_day = null; + // TODO: Actually calculate teh reminder and deadline days + if (this.byDayOfMonthTarget.checked && this.dayOfMonthTarget.value) { + reminder_day = this.dayOfMonthTarget.value; + } + if (this.byDayOfWeekTarget.checked && this.everyNthDayTarget.value && (this.dayOfWeekTarget.value || this.dayOfWeekTarget === 0)) { + reminder_day = "by week day"; + } + if (reminder_day && this.deadlineDayTarget.value) { + deadline_day = this.deadlineDayTarget.value; + } + + if (reminder_day && deadline_day && reminder_day == deadline_day) { + $(this.reminderTextTarget).removeClass('text-muted').addClass('text-danger'); + $(this.reminderTextTarget).text('Reminder day cannot be the same as deadline day.'); + $(this.deadlineTextTarget).text(""); + } else { + $(this.reminderTextTarget).removeClass('text-danger').addClass('text-muted'); + $(this.reminderTextTarget).text(reminder_day ? `Your next reminder will be sent on ${reminder_day} ${"month"}.` : ""); + $(this.deadlineTextTarget).text(deadline_day ? `Your next deadline will be on ${deadline_day} ${"month"}.` : ""); + } + } + + monthOrWeekChanged() { + $(this.dayOfMonthFieldsTarget).toggleClass("d-none", !this.byDayOfMonthTarget.checked ); + $(this.dayOfWeekFieldsTarget).toggleClass("d-none", !this.byDayOfWeekTarget.checked ); + } + + connect() { + this.monthOrWeekChanged() + this.sourceChange() + } +} diff --git a/app/javascript/utils/deadline_day_pickers.js b/app/javascript/utils/deadline_day_pickers.js deleted file mode 100644 index 1ab5082ec2..0000000000 --- a/app/javascript/utils/deadline_day_pickers.js +++ /dev/null @@ -1,79 +0,0 @@ -import $ from 'jquery'; - -$(document).ready(function () { - const container_selector = '.deadline-day-pickers'; - const reminder_selector = '.deadline-day-pickers__reminder-day'; - const deadline_selector = '.deadline-day-pickers__deadline-day'; - const reminder_container_selector = '.deadline-day-pickers__reminder-container'; - const deadline_container_selector = '.deadline-day-pickers__deadline-container'; - const day_of_week_toggle_selector = '#toggle-to-week-day'; - - const reminder_text_selector = '.deadline-day-pickers__reminder-day-text'; - const deadline_text_selector = '.deadline-day-pickers__deadline-day-text'; - - const server_validation_selector = '.invalid-feedback'; - - function refresh_text(container) { - const $container = $(container); - const $reminder = $container.find(reminder_selector); - const $deadline = $container.find(deadline_selector); - const $reminder_text = $container.find(reminder_text_selector); - const $deadline_text = $container.find(deadline_text_selector); - const $day_of_week_toggle = $container.find(day_of_week_toggle_selector)[0]; - - const reminder_day = parseInt($reminder.val()); - const deadline_day = parseInt($deadline.val()); - const current_day = parseInt($container.data('current-day')); - - const current_month = $container.data('current-month'); - const next_month = $container.data('next-month'); - - if (reminder_day) { - $(container).find(reminder_container_selector).find(server_validation_selector).remove(); - - if (reminder_day === deadline_day && !$day_of_week_toggle.checked) { - $reminder_text.removeClass('text-muted').addClass('text-danger'); - - $reminder_text.text('Reminder day cannot be the same as deadline day.'); - } - else if ($day_of_week_toggle.checked){ - $reminder_text.text(''); - } - else { - $reminder_text.removeClass('text-danger').addClass('text-muted'); - const next_reminder_month = (current_day >= reminder_day) ? next_month : current_month; - $reminder_text.text(`Your next reminder will be sent on ${reminder_day} ${next_reminder_month}.`); - } - } - - if (deadline_day) { - $(container).find(deadline_container_selector).find(server_validation_selector).remove(); - - const next_deadline_month = (current_day >= deadline_day) ? next_month : current_month; - $deadline_text.text(`Your next deadline will be on ${deadline_day} ${next_deadline_month}`); - } - } - - $(container_selector).each(function(_, container) { - refresh_text(container); - }) - - $(document).on('input', [reminder_selector, deadline_selector], function(evt) { - const target = evt.target; - const $target = $(target); - const $container = $target.closest(container_selector); - - const value = parseInt($target.val()); - - const min = parseInt($container.data('min')); - const max = parseInt($container.data('max')); - - if (value < min) { - $target.val(max); - } else if (value > max) { - $target.val(min); - } - - refresh_text($container); - }) -}) diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index 772f65c0ec..b5b7bc2cff 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -1,68 +1,69 @@ -<%= tag.div(class: 'deadline-day-pickers', - data: { - current_day: Date.current.mday, - current_month: Date::MONTHNAMES[Date.current.month], - next_month: Date::MONTHNAMES[Date.current.next_month.month] - }) do %> +
- <%= f.label :every_nth_month, 'How frequently should reminders be sent (e.g. "monthly", "every 3 months", etc.)?' %> - - <%= f.input :every_nth_month, - collection: Deadlinable::EVERY_NTH_MONTH_COLLECTION, - class: "deadline-day-pickers__reminder-day form-control", - label: false, - show_blank: true, - default: 1, - :input_html => {:style=> 'width: 200px'} - %> + <%= f.label :every_nth_month, 'How frequently should reminders be sent (e.g. "monthly", "every 3 months", etc.)?' %> + <%= f.input :every_nth_month, + collection: Deadlinable::EVERY_NTH_MONTH_COLLECTION, + class: "form-control", + label: false, + show_blank: true, + default: 1, + input_html: {style: 'width: 200px', "data-deadline-day-target" => "everyNthMonth", "data-action" => "deadline-day#sourceChange"} %> - <%= f.label :by_month_or_week, 'Send reminders on a specific day of the month (e.g. "the 5th") or a day of the week (eg "the first Tuesday")?' %> -
- <%= f.radio_button :by_month_or_week, 'day_of_month', label: 'Day of Month', id: 'toggle-to-date' %> - <%= f.label :by_month_or_week, 'Day of Month' %> -
- <%= f.radio_button :by_month_or_week, 'day_of_week', label: 'Day of the Week', id: 'toggle-to-week-day' %> - <%= f.label :by_month_or_week, 'Day of the Week' %> + <%= f.label :by_month_or_week, 'Send reminders on a specific day of the month (e.g. "the 5th") or a day of the week (eg "the first Tuesday")?' %> +
+ <%= f.radio_button :by_month_or_week, + 'day_of_month', + label: 'Day of Month', + data: { 'deadline-day-target': 'byDayOfMonth', 'action': 'deadline-day#monthOrWeekChanged deadline-day#sourceChange' } %> + <%= f.label :by_month_or_week, 'Day of Month' %> +
+ <%= f.radio_button :by_month_or_week, + 'day_of_week', + label: 'Day of the Week', + data: { 'deadline-day-target': 'byDayOfWeek', 'action': 'deadline-day#monthOrWeekChanged deadline-day#sourceChange' } %> + <%= f.label :by_month_or_week, 'Day of the Week' %> -
- <%= f.input :day_of_month, as: :integer, wrapper: :input_group, - label: 'Reminder date' do %> +
+ <%= f.input :day_of_month, as: :integer, wrapper: :input_group, label: 'Reminder date' do %> <%= f.number_field :day_of_month, - min: Deadlinable::MIN_DAY_OF_MONTH, - max: Deadlinable::MAX_DAY_OF_MONTH, - class: "deadline-day-pickers__reminder-day form-control", - placeholder: "Reminder day" %> - <% end %> -
- - -
- <%= f.input :every_nth_day, wrapper: :input_group, wrapper_html: { class: 'mb-3'}, - label: 'Reminder day of the week' do %> - <%= f.input :every_nth_day, collection: Deadlinable::EVERY_NTH_COLLECTION, - class: "deadline-day-pickers__reminder-day form-control", - label: false %> + min: Deadlinable::MIN_DAY_OF_MONTH, + max: Deadlinable::MAX_DAY_OF_MONTH, + class: "form-control", + placeholder: "Reminder day", + data: { 'deadline-day-target': 'dayOfMonth', 'action': 'deadline-day#sourceChange' } %> + <% end %> +
- <%= f.input :day_of_week, collection: Deadlinable::DAY_OF_WEEK_COLLECTION, - class: "deadline-day-pickers__reminder-day form-control", +
+ <%= f.label :every_nth_day, 'Reminder day of the week' %> +
+ <%= f.input :every_nth_day, + collection: Deadlinable::EVERY_NTH_COLLECTION, + class: "form-control", label: false, - show_blank: true, - default: 1, - :input_html => {:style=> 'width: 200px'} %> -
- <% end %> + input_html: {"data-deadline-day-target" => "everyNthDay", "data-action" => "deadline-day#sourceChange"} %> -
- <%= f.input :deadline_day, wrapper: :input_group, wrapper_html: { class: 'mb-0', min: 0, max: 28 }, - label: 'Default deadline day (final day of month to submit Requests)' do %> - - <%= f.number_field :deadline_day, - min: Deadlinable::MIN_DAY_OF_MONTH, - max: Deadlinable::MAX_DAY_OF_MONTH, - class: "deadline-day-pickers__deadline-day form-control", - placeholder: "Deadline day" %> - <% end %> - + <%= f.input :day_of_week, + collection: Deadlinable::DAY_OF_WEEK_COLLECTION, + class: "form-control", + label: false, + show_blank: true, + default: 1, + input_html: {style: 'width: 200px', "data-deadline-day-target" => "dayOfWeek", "data-action" => "deadline-day#sourceChange"} %> +
-<% end %> + + + + <%= f.input :deadline_day, wrapper: :input_group, wrapper_html: { min: 0, max: 28 }, label: 'Default deadline day (final day of month to submit Requests)' do %> + + <%= f.number_field :deadline_day, + min: Deadlinable::MIN_DAY_OF_MONTH, + max: Deadlinable::MAX_DAY_OF_MONTH, + class: "form-control", + placeholder: "Deadline day", + data: {"deadline-day-target": "deadlineDay", "action": "deadline-day#sourceChange"} %> + <% end %> + +
From ed5b1b1951f69933449e8fcbf4297f644ff620af Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 30 Apr 2025 11:54:23 -0600 Subject: [PATCH 19/94] Updated deadline_day_controller to calculate the reminder and deadline dates using new rrule library --- .../controllers/deadline_day_controller.js | 50 ++++-- config/importmap.rb | 2 + vendor/javascript/rrule.js | 157 ++++++++++++++++++ vendor/javascript/tslib.js | 6 + 4 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 vendor/javascript/rrule.js create mode 100644 vendor/javascript/tslib.js diff --git a/app/javascript/controllers/deadline_day_controller.js b/app/javascript/controllers/deadline_day_controller.js index 6c61abe4b2..0d58cbbfad 100644 --- a/app/javascript/controllers/deadline_day_controller.js +++ b/app/javascript/controllers/deadline_day_controller.js @@ -1,5 +1,17 @@ import { Controller } from "@hotwired/stimulus" import $ from 'jquery'; +import { RRule } from 'rrule' +import 'tslib' + +const WEEKDAY_NUM_TO_OBJ = { + 0: RRule.SU, + 1: RRule.MO, + 2: RRule.TU, + 3: RRule.WE, + 4: RRule.TH, + 5: RRule.FR, + 6: RRule.SA +} export default class extends Controller { static targets = [ @@ -8,27 +20,43 @@ export default class extends Controller { ] sourceChange() { - let reminder_day = null; - let deadline_day = null; - // TODO: Actually calculate teh reminder and deadline days + let reminder_date = null; + let deadline_date = null; if (this.byDayOfMonthTarget.checked && this.dayOfMonthTarget.value) { - reminder_day = this.dayOfMonthTarget.value; + const rule = new RRule({ + freq: RRule.MONTHLY, + interval: parseInt(this.everyNthMonthTarget.value), + bymonthday: parseInt(this.dayOfMonthTarget.value), + count: 1, + }) + reminder_date = rule.all()[0] } - if (this.byDayOfWeekTarget.checked && this.everyNthDayTarget.value && (this.dayOfWeekTarget.value || this.dayOfWeekTarget === 0)) { - reminder_day = "by week day"; + if (this.byDayOfWeekTarget.checked && this.everyNthDayTarget.value && (this.dayOfWeekTarget.value)) { + const rule = new RRule({ + freq: RRule.MONTHLY, + interval: parseInt(this.everyNthMonthTarget.value), + byweekday: WEEKDAY_NUM_TO_OBJ[ parseInt(this.dayOfWeekTarget.value) ].nth( parseInt(this.everyNthDayTarget.value) ), + wkst: RRule.SU, + count: 1 + }) + reminder_date = rule.all()[0] } - if (reminder_day && this.deadlineDayTarget.value) { - deadline_day = this.deadlineDayTarget.value; + if (reminder_date && this.deadlineDayTarget.value) { + deadline_date = new Date(reminder_date.getTime()); + if( deadline_date.getDate() >= parseInt(this.deadlineDayTarget.value)){ + deadline_date.setMonth( deadline_date.getMonth() + 1 ) + } + deadline_date.setDate(parseInt(this.deadlineDayTarget.value)) } - if (reminder_day && deadline_day && reminder_day == deadline_day) { + if (reminder_date && deadline_date && reminder_date.getTime() === deadline_date.getTime()) { $(this.reminderTextTarget).removeClass('text-muted').addClass('text-danger'); $(this.reminderTextTarget).text('Reminder day cannot be the same as deadline day.'); $(this.deadlineTextTarget).text(""); } else { $(this.reminderTextTarget).removeClass('text-danger').addClass('text-muted'); - $(this.reminderTextTarget).text(reminder_day ? `Your next reminder will be sent on ${reminder_day} ${"month"}.` : ""); - $(this.deadlineTextTarget).text(deadline_day ? `Your next deadline will be on ${deadline_day} ${"month"}.` : ""); + $(this.reminderTextTarget).text(reminder_date ? `Your next reminder will be sent on ${reminder_date.toDateString()}.` : ""); + $(this.deadlineTextTarget).text(deadline_date ? `Your next deadline will be on ${deadline_date.toDateString()}.` : ""); } } diff --git a/config/importmap.rb b/config/importmap.rb index 1adc04d365..88b505d657 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -34,3 +34,5 @@ pin "bootstrap-select", to: "https://ga.jspm.io/npm:bootstrap-select@1.13.18/dist/js/bootstrap-select.js" pin "jquery-ui", to: "https://ga.jspm.io/npm:jquery-ui@1.13.2/ui/widget.js" pin "@rails/activestorage", to: "@rails--activestorage.js" # @8.0.100 +pin "rrule", to: "https://ga.jspm.io/npm:rrule@2.8.1/dist/esm/index.js" # @2.8.1 +pin "tslib", to: "https://ga.jspm.io/npm:tslib@2.8.1/tslib.es6.mjs" # @2.8.1 diff --git a/vendor/javascript/rrule.js b/vendor/javascript/rrule.js new file mode 100644 index 0000000000..3a99735d2d --- /dev/null +++ b/vendor/javascript/rrule.js @@ -0,0 +1,157 @@ +// rrule@2.8.1 downloaded from https://ga.jspm.io/npm:rrule@2.8.1/dist/esm/index.js + +import{__extends as e,__assign as t,__spreadArray as r}from"tslib";import n from"./nlp/i18n.js";var a=["MO","TU","WE","TH","FR","SA","SU"];var i=function(){function Weekday(e,t){if(t===0)throw new Error("Can't create weekday with n == 0");this.weekday=e;this.n=t}Weekday.fromStr=function(e){return new Weekday(a.indexOf(e))};Weekday.prototype.nth=function(e){return this.n===e?this:new Weekday(this.weekday,e)};Weekday.prototype.equals=function(e){return this.weekday===e.weekday&&this.n===e.n};Weekday.prototype.toString=function(){var e=a[this.weekday];this.n&&(e=(this.n>0?"+":"")+String(this.n)+e);return e};Weekday.prototype.getJsWeekday=function(){return this.weekday===6?0:this.weekday+1};return Weekday}();var isPresent=function(e){return e!==null&&e!==void 0};var isNumber=function(e){return typeof e==="number"};var isWeekdayStr=function(e){return typeof e==="string"&&a.includes(e)};var o=Array.isArray;var range=function(e,t){t===void 0&&(t=e);if(arguments.length===1){t=e;e=0}var r=[];for(var n=e;n>=0;if(n.length>t)return String(n);t-=n.length;t>r.length&&(r+=repeat(r,t/r.length));return r.slice(0,t)+String(n)}var split=function(e,t,r){var n=e.split(t);return r?n.slice(0,r).concat([n.slice(r).join(t)]):n}; +/** + * closure/goog/math/math.js:modulo + * Copyright 2006 The Closure Library Authors. + * The % operator in JavaScript returns the remainder of a / b, but differs from + * some other languages in that the result will have the same sign as the + * dividend. For example, -1 % 8 == -1, whereas in some other languages + * (such as Python) the result would be 7. This function emulates the more + * correct modulo behavior, which is useful for certain applications such as + * calculating an offset index in a circular list. + * + * @param {number} a The dividend. + * @param {number} b The divisor. + * @return {number} a % b where the result is between 0 and b (either 0 <= x < b + * or b < x <= 0, depending on the sign of b). + */var pymod=function(e,t){var r=e%t;return r*t<0?r+t:r};var divmod=function(e,t){return{div:Math.floor(e/t),mod:pymod(e,t)}};var empty=function(e){return!isPresent(e)||e.length===0};var notEmpty=function(e){return!empty(e)};var includes=function(e,t){return notEmpty(e)&&e.indexOf(t)!==-1};var datetime=function(e,t,r,n,a,i){n===void 0&&(n=0);a===void 0&&(a=0);i===void 0&&(i=0);return new Date(Date.UTC(e,t-1,r,n,a,i))};var s=[31,28,31,30,31,30,31,31,30,31,30,31];var u=864e5;var h=9999;var l=datetime(1970,1,1);var d=[6,0,1,2,3,4,5];var isLeapYear=function(e){return e%4===0&&e%100!==0||e%400===0};var isDate=function(e){return e instanceof Date};var isValidDate=function(e){return isDate(e)&&!isNaN(e.getTime())};var daysBetween=function(e,t){var r=e.getTime();var n=t.getTime();var a=r-n;return Math.round(a/u)};var toOrdinal=function(e){return daysBetween(e,l)};var fromOrdinal=function(e){return new Date(l.getTime()+e*u)};var getMonthDays=function(e){var t=e.getUTCMonth();return t===1&&isLeapYear(e.getUTCFullYear())?29:s[t]};var getWeekday=function(e){return d[e.getUTCDay()]};var monthRange=function(e,t){var r=datetime(e,t+1,1);return[getWeekday(r),getMonthDays(r)]};var combine=function(e,t){t=t||e;return new Date(Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate(),t.getHours(),t.getMinutes(),t.getSeconds(),t.getMilliseconds()))};var clone=function(e){var t=new Date(e.getTime());return t};var cloneDates=function(e){var t=[];for(var r=0;rthis.maxDate;if(this.method==="between"){if(t)return true;if(r)return false}else if(this.method==="before"){if(r)return false}else if(this.method==="after"){if(t)return true;this.add(e);return false}return this.add(e)}; +/** + * + * @param {Date} date that is part of the result. + * @return {Boolean} whether we are interested in more values. + */IterResult.prototype.add=function(e){this._result.push(e);return true};IterResult.prototype.getValue=function(){var e=this._result;switch(this.method){case"all":case"between":return e;case"before":case"after":default:return e.length?e[e.length-1]:null}};IterResult.prototype.clone=function(){return new IterResult(this.method,this.args)};return IterResult}();var y=function(t){e(CallbackIterResult,t);function CallbackIterResult(e,r,n){var a=t.call(this,e,r)||this;a.iterator=n;return a}CallbackIterResult.prototype.add=function(e){if(this.iterator(e,this._result.length)){this._result.push(e);return true}return false};return CallbackIterResult}(c);var contains=function(e,t){return e.indexOf(t)!==-1};var defaultGetText=function(e){return e.toString()};var defaultDateFormatter=function(e,t,r){return"".concat(t," ").concat(r,", ").concat(e)}; +/** + * + * @param {RRule} rrule + * Optional: + * @param {Function} gettext function + * @param {Object} language definition + * @constructor + */var f=function(){function ToText(e,t,r,a){t===void 0&&(t=defaultGetText);r===void 0&&(r=n);a===void 0&&(a=defaultDateFormatter);this.text=[];this.language=r||n;this.gettext=t;this.dateFormatter=a;this.rrule=e;this.options=e.options;this.origOptions=e.origOptions;if(this.origOptions.bymonthday){var i=[].concat(this.options.bymonthday);var s=[].concat(this.options.bynmonthday);i.sort((function(e,t){return e-t}));s.sort((function(e,t){return t-e}));this.bymonthday=i.concat(s);this.bymonthday.length||(this.bymonthday=null)}if(isPresent(this.origOptions.byweekday)){var u=o(this.origOptions.byweekday)?this.origOptions.byweekday:[this.origOptions.byweekday];var h=String(u);this.byweekday={allWeeks:u.filter((function(e){return!e.n})),someWeeks:u.filter((function(e){return Boolean(e.n)})),isWeekdays:h.indexOf("MO")!==-1&&h.indexOf("TU")!==-1&&h.indexOf("WE")!==-1&&h.indexOf("TH")!==-1&&h.indexOf("FR")!==-1&&h.indexOf("SA")===-1&&h.indexOf("SU")===-1,isEveryDay:h.indexOf("MO")!==-1&&h.indexOf("TU")!==-1&&h.indexOf("WE")!==-1&&h.indexOf("TH")!==-1&&h.indexOf("FR")!==-1&&h.indexOf("SA")!==-1&&h.indexOf("SU")!==-1};var sortWeekDays=function(e,t){return e.weekday-t.weekday};this.byweekday.allWeeks.sort(sortWeekDays);this.byweekday.someWeeks.sort(sortWeekDays);this.byweekday.allWeeks.length||(this.byweekday.allWeeks=null);this.byweekday.someWeeks.length||(this.byweekday.someWeeks=null)}else this.byweekday=null} +/** + * Test whether the rrule can be fully converted to text. + * + * @param {RRule} rrule + * @return {Boolean} + */ToText.isFullyConvertible=function(e){var t=true;if(!(e.options.freq in ToText.IMPLEMENTED))return false;if(e.origOptions.until&&e.origOptions.count)return false;for(var r in e.origOptions){if(contains(["dtstart","tzid","wkst","freq"],r))return true;if(!contains(ToText.IMPLEMENTED[e.options.freq],r))return false}return t};ToText.prototype.isFullyConvertible=function(){return ToText.isFullyConvertible(this.rrule)};ToText.prototype.toString=function(){var e=this.gettext;if(!(this.options.freq in ToText.IMPLEMENTED))return e("RRule error: Unable to fully convert this rrule to text");this.text=[e("every")];this[Z.FREQUENCIES[this.options.freq]]();if(this.options.until){this.add(e("until"));var t=this.options.until;this.add(this.dateFormatter(t.getUTCFullYear(),this.language.monthNames[t.getUTCMonth()],t.getUTCDate()))}else this.options.count&&this.add(e("for")).add(this.options.count.toString()).add(this.plural(this.options.count)?e("times"):e("time"));this.isFullyConvertible()||this.add(e("(~ approximate)"));return this.text.join("")};ToText.prototype.HOURLY=function(){var e=this.gettext;this.options.interval!==1&&this.add(this.options.interval.toString());this.add(this.plural(this.options.interval)?e("hours"):e("hour"))};ToText.prototype.MINUTELY=function(){var e=this.gettext;this.options.interval!==1&&this.add(this.options.interval.toString());this.add(this.plural(this.options.interval)?e("minutes"):e("minute"))};ToText.prototype.DAILY=function(){var e=this.gettext;this.options.interval!==1&&this.add(this.options.interval.toString());this.byweekday&&this.byweekday.isWeekdays?this.add(this.plural(this.options.interval)?e("weekdays"):e("weekday")):this.add(this.plural(this.options.interval)?e("days"):e("day"));if(this.origOptions.bymonth){this.add(e("in"));this._bymonth()}this.bymonthday?this._bymonthday():this.byweekday?this._byweekday():this.origOptions.byhour&&this._byhour()};ToText.prototype.WEEKLY=function(){var e=this.gettext;this.options.interval!==1&&this.add(this.options.interval.toString()).add(this.plural(this.options.interval)?e("weeks"):e("week"));if(this.byweekday&&this.byweekday.isWeekdays)this.options.interval===1?this.add(this.plural(this.options.interval)?e("weekdays"):e("weekday")):this.add(e("on")).add(e("weekdays"));else if(this.byweekday&&this.byweekday.isEveryDay)this.add(this.plural(this.options.interval)?e("days"):e("day"));else{this.options.interval===1&&this.add(e("week"));if(this.origOptions.bymonth){this.add(e("in"));this._bymonth()}this.bymonthday?this._bymonthday():this.byweekday&&this._byweekday();this.origOptions.byhour&&this._byhour()}};ToText.prototype.MONTHLY=function(){var e=this.gettext;if(this.origOptions.bymonth){if(this.options.interval!==1){this.add(this.options.interval.toString()).add(e("months"));this.plural(this.options.interval)&&this.add(e("in"))}this._bymonth()}else{this.options.interval!==1&&this.add(this.options.interval.toString());this.add(this.plural(this.options.interval)?e("months"):e("month"))}this.bymonthday?this._bymonthday():this.byweekday&&this.byweekday.isWeekdays?this.add(e("on")).add(e("weekdays")):this.byweekday&&this._byweekday()};ToText.prototype.YEARLY=function(){var e=this.gettext;if(this.origOptions.bymonth){if(this.options.interval!==1){this.add(this.options.interval.toString());this.add(e("years"))}this._bymonth()}else{this.options.interval!==1&&this.add(this.options.interval.toString());this.add(this.plural(this.options.interval)?e("years"):e("year"))}this.bymonthday?this._bymonthday():this.byweekday&&this._byweekday();this.options.byyearday&&this.add(e("on the")).add(this.list(this.options.byyearday,this.nth,e("and"))).add(e("day"));this.options.byweekno&&this.add(e("in")).add(this.plural(this.options.byweekno.length)?e("weeks"):e("week")).add(this.list(this.options.byweekno,void 0,e("and")))};ToText.prototype._bymonthday=function(){var e=this.gettext;this.byweekday&&this.byweekday.allWeeks?this.add(e("on")).add(this.list(this.byweekday.allWeeks,this.weekdaytext,e("or"))).add(e("the")).add(this.list(this.bymonthday,this.nth,e("or"))):this.add(e("on the")).add(this.list(this.bymonthday,this.nth,e("and")))};ToText.prototype._byweekday=function(){var e=this.gettext;this.byweekday.allWeeks&&!this.byweekday.isWeekdays&&this.add(e("on")).add(this.list(this.byweekday.allWeeks,this.weekdaytext));if(this.byweekday.someWeeks){this.byweekday.allWeeks&&this.add(e("and"));this.add(e("on the")).add(this.list(this.byweekday.someWeeks,this.weekdaytext,e("and")))}};ToText.prototype._byhour=function(){var e=this.gettext;this.add(e("at")).add(this.list(this.origOptions.byhour,void 0,e("and")))};ToText.prototype._bymonth=function(){this.add(this.list(this.options.bymonth,this.monthtext,this.gettext("and")))};ToText.prototype.nth=function(e){e=parseInt(e.toString(),10);var t;var r=this.gettext;if(e===-1)return r("last");var n=Math.abs(e);switch(n){case 1:case 21:case 31:t=n+r("st");break;case 2:case 22:t=n+r("nd");break;case 3:case 23:t=n+r("rd");break;default:t=n+r("th")}return e<0?t+" "+r("last"):t};ToText.prototype.monthtext=function(e){return this.language.monthNames[e-1]};ToText.prototype.weekdaytext=function(e){var t=isNumber(e)?(e+1)%7:e.getJsWeekday();return(e.n?this.nth(e.n)+" ":"")+this.language.dayNames[t]};ToText.prototype.plural=function(e){return e%100!==1};ToText.prototype.add=function(e){this.text.push(" ");this.text.push(e);return this};ToText.prototype.list=function(e,t,r,n){var a=this;n===void 0&&(n=",");o(e)||(e=[e]);var delimJoin=function(e,t,r){var n="";for(var a=0;ae[0].length)){e=a;t=n}}if(e!=null){this.text=this.text.substr(e[0].length);this.text===""&&(this.done=true)}if(e==null){this.done=true;this.symbol=null;this.value=null;return}}while(t==="SKIP");this.symbol=t;this.value=e;return true};Parser.prototype.accept=function(e){if(this.symbol===e){if(this.value){var t=this.value;this.nextSymbol();return t}this.nextSymbol();return true}return false};Parser.prototype.acceptNumber=function(){return this.accept("number")};Parser.prototype.expect=function(e){if(this.accept(e))return true;throw new Error("expected "+e+" but found "+this.symbol)};return Parser}();function parseText(e,t){t===void 0&&(t=n);var r={};var a=new p(t.tokens);if(!a.start(e))return null;S();return r;function S(){a.expect("every");var e=a.acceptNumber();e&&(r.interval=parseInt(e[0],10));if(a.isDone())throw new Error("Unexpected end");switch(a.symbol){case"day(s)":r.freq=Z.DAILY;if(a.nextSymbol()){AT();F()}break;case"weekday(s)":r.freq=Z.WEEKLY;r.byweekday=[Z.MO,Z.TU,Z.WE,Z.TH,Z.FR];a.nextSymbol();AT();F();break;case"week(s)":r.freq=Z.WEEKLY;if(a.nextSymbol()){ON();AT();F()}break;case"hour(s)":r.freq=Z.HOURLY;if(a.nextSymbol()){ON();F()}break;case"minute(s)":r.freq=Z.MINUTELY;if(a.nextSymbol()){ON();F()}break;case"month(s)":r.freq=Z.MONTHLY;if(a.nextSymbol()){ON();F()}break;case"year(s)":r.freq=Z.YEARLY;if(a.nextSymbol()){ON();F()}break;case"monday":case"tuesday":case"wednesday":case"thursday":case"friday":case"saturday":case"sunday":r.freq=Z.WEEKLY;var t=a.symbol.substr(0,2).toUpperCase();r.byweekday=[Z[t]];if(!a.nextSymbol())return;while(a.accept("comma")){if(a.isDone())throw new Error("Unexpected end");var n=decodeWKD();if(!n)throw new Error("Unexpected symbol "+a.symbol+", expected weekday");r.byweekday.push(Z[n]);a.nextSymbol()}AT();MDAYs();F();break;case"january":case"february":case"march":case"april":case"may":case"june":case"july":case"august":case"september":case"october":case"november":case"december":r.freq=Z.YEARLY;r.bymonth=[decodeM()];if(!a.nextSymbol())return;while(a.accept("comma")){if(a.isDone())throw new Error("Unexpected end");var i=decodeM();if(!i)throw new Error("Unexpected symbol "+a.symbol+", expected month");r.bymonth.push(i);a.nextSymbol()}ON();F();break;default:throw new Error("Unknown symbol")}}function ON(){var e=a.accept("on");var t=a.accept("the");if(e||t)do{var n=decodeNTH();var i=decodeWKD();var o=decodeM();if(n)if(i){a.nextSymbol();r.byweekday||(r.byweekday=[]);r.byweekday.push(Z[i].nth(n))}else{r.bymonthday||(r.bymonthday=[]);r.bymonthday.push(n);a.accept("day(s)")}else if(i){a.nextSymbol();r.byweekday||(r.byweekday=[]);r.byweekday.push(Z[i])}else if(a.symbol==="weekday(s)"){a.nextSymbol();r.byweekday||(r.byweekday=[Z.MO,Z.TU,Z.WE,Z.TH,Z.FR])}else if(a.symbol==="week(s)"){a.nextSymbol();var s=a.acceptNumber();if(!s)throw new Error("Unexpected symbol "+a.symbol+", expected week number");r.byweekno=[parseInt(s[0],10)];while(a.accept("comma")){s=a.acceptNumber();if(!s)throw new Error("Unexpected symbol "+a.symbol+"; expected monthday");r.byweekno.push(parseInt(s[0],10))}}else{if(!o)return;a.nextSymbol();r.bymonth||(r.bymonth=[]);r.bymonth.push(o)}}while(a.accept("comma")||a.accept("the")||a.accept("on"))}function AT(){var e=a.accept("at");if(e)do{var t=a.acceptNumber();if(!t)throw new Error("Unexpected symbol "+a.symbol+", expected hour");r.byhour=[parseInt(t[0],10)];while(a.accept("comma")){t=a.acceptNumber();if(!t)throw new Error("Unexpected symbol "+a.symbol+"; expected hour");r.byhour.push(parseInt(t[0],10))}}while(a.accept("comma")||a.accept("at"))}function decodeM(){switch(a.symbol){case"january":return 1;case"february":return 2;case"march":return 3;case"april":return 4;case"may":return 5;case"june":return 6;case"july":return 7;case"august":return 8;case"september":return 9;case"october":return 10;case"november":return 11;case"december":return 12;default:return false}}function decodeWKD(){switch(a.symbol){case"monday":case"tuesday":case"wednesday":case"thursday":case"friday":case"saturday":case"sunday":return a.symbol.substr(0,2).toUpperCase();default:return false}}function decodeNTH(){switch(a.symbol){case"last":a.nextSymbol();return-1;case"first":a.nextSymbol();return 1;case"second":a.nextSymbol();return a.accept("last")?-2:2;case"third":a.nextSymbol();return a.accept("last")?-3:3;case"nth":var e=parseInt(a.value[1],10);if(e<-366||e>366)throw new Error("Nth out of range: "+e);a.nextSymbol();return a.accept("last")?-e:e;default:return false}}function MDAYs(){a.accept("on");a.accept("the");var e=decodeNTH();if(e){r.bymonthday=[e];a.nextSymbol();while(a.accept("comma")){e=decodeNTH();if(!e)throw new Error("Unexpected symbol "+a.symbol+"; expected monthday");r.bymonthday.push(e);a.nextSymbol()}}}function F(){if(a.symbol==="until"){var e=Date.parse(a.text);if(!e)throw new Error("Cannot parse until date:"+a.text);r.until=new Date(e)}else if(a.accept("for")){r.count=parseInt(a.value[0],10);a.expect("number")}}}var m;(function(e){e[e.YEARLY=0]="YEARLY";e[e.MONTHLY=1]="MONTHLY";e[e.WEEKLY=2]="WEEKLY";e[e.DAILY=3]="DAILY";e[e.HOURLY=4]="HOURLY";e[e.MINUTELY=5]="MINUTELY";e[e.SECONDLY=6]="SECONDLY"})(m||(m={}));function freqIsDailyOrGreater(e){return e12){var t=Math.floor(this.month/12);var r=pymod(this.month,12);this.month=r;this.year+=t;if(this.month===0){this.month=12;--this.year}}};DateTime.prototype.addWeekly=function(e,t){t>this.getWeekday()?this.day+=-(this.getWeekday()+1+(6-t))+e*7:this.day+=-(this.getWeekday()-t)+e*7;this.fixDay()};DateTime.prototype.addDaily=function(e){this.day+=e;this.fixDay()};DateTime.prototype.addHours=function(e,t,r){t&&(this.hour+=Math.floor((23-this.hour)/e)*e);for(;;){this.hour+=e;var n=divmod(this.hour,24),a=n.div,i=n.mod;if(a){this.hour=i;this.addDaily(a)}if(empty(r)||includes(r,this.hour))break}};DateTime.prototype.addMinutes=function(e,t,r,n){t&&(this.minute+=Math.floor((1439-(this.hour*60+this.minute))/e)*e);for(;;){this.minute+=e;var a=divmod(this.minute,60),i=a.div,o=a.mod;if(i){this.minute=o;this.addHours(i,false,r)}if((empty(r)||includes(r,this.hour))&&(empty(n)||includes(n,this.minute)))break}};DateTime.prototype.addSeconds=function(e,t,r,n,a){t&&(this.second+=Math.floor((86399-(this.hour*3600+this.minute*60+this.second))/e)*e);for(;;){this.second+=e;var i=divmod(this.second,60),o=i.div,s=i.mod;if(o){this.second=s;this.addMinutes(o,false,r,n)}if((empty(r)||includes(r,this.hour))&&(empty(n)||includes(n,this.minute))&&(empty(a)||includes(a,this.second)))break}};DateTime.prototype.fixDay=function(){if(!(this.day<=28)){var e=monthRange(this.year,this.month-1)[1];if(!(this.day<=e))while(this.day>e){this.day-=e;++this.month;if(this.month===13){this.month=1;++this.year;if(this.year>h)return}e=monthRange(this.year,this.month-1)[1]}}};DateTime.prototype.add=function(e,t){var r=e.freq,n=e.interval,a=e.wkst,i=e.byhour,o=e.byminute,s=e.bysecond;switch(r){case m.YEARLY:return this.addYears(n);case m.MONTHLY:return this.addMonths(n);case m.WEEKLY:return this.addWeekly(n,a);case m.DAILY:return this.addDaily(n);case m.HOURLY:return this.addHours(n,t,i);case m.MINUTELY:return this.addMinutes(n,t,i,o);case m.SECONDLY:return this.addSeconds(n,t,i,o,s)}};return DateTime}(w);function initializeOptions$1(e){var r=[];var n=Object.keys(e);for(var a=0,i=n;a=-366&&a<=366))throw new Error("bysetpos must be between 1 and 366, or between -366 and -1")}}if(!(Boolean(r.byweekno)||notEmpty(r.byweekno)||notEmpty(r.byyearday)||Boolean(r.bymonthday)||notEmpty(r.bymonthday)||isPresent(r.byweekday)||isPresent(r.byeaster)))switch(r.freq){case Z.YEARLY:r.bymonth||(r.bymonth=r.dtstart.getUTCMonth()+1);r.bymonthday=r.dtstart.getUTCDate();break;case Z.MONTHLY:r.bymonthday=r.dtstart.getUTCDate();break;case Z.WEEKLY:r.byweekday=[getWeekday(r.dtstart)];break}isPresent(r.bymonth)&&!o(r.bymonth)&&(r.bymonth=[r.bymonth]);isPresent(r.byyearday)&&!o(r.byyearday)&&isNumber(r.byyearday)&&(r.byyearday=[r.byyearday]);if(isPresent(r.bymonthday))if(o(r.bymonthday)){var s=[];var u=[];for(n=0;n0?s.push(a):a<0&&u.push(a)}r.bymonthday=s;r.bynmonthday=u}else if(r.bymonthday<0){r.bynmonthday=[r.bymonthday];r.bymonthday=[]}else{r.bynmonthday=[];r.bymonthday=[r.bymonthday]}else{r.bymonthday=[];r.bynmonthday=[]}isPresent(r.byweekno)&&!o(r.byweekno)&&(r.byweekno=[r.byweekno]);if(isPresent(r.byweekday))if(isNumber(r.byweekday)){r.byweekday=[r.byweekday];r.bynweekday=null}else if(isWeekdayStr(r.byweekday)){r.byweekday=[i.fromStr(r.byweekday).weekday];r.bynweekday=null}else if(r.byweekday instanceof i)if(!r.byweekday.n||r.freq>Z.MONTHLY){r.byweekday=[r.byweekday.weekday];r.bynweekday=null}else{r.bynweekday=[[r.byweekday.weekday,r.byweekday.n]];r.byweekday=null}else{var h=[];var l=[];for(n=0;nZ.MONTHLY?h.push(d.weekday):l.push([d.weekday,d.n])}r.byweekday=notEmpty(h)?h:null;r.bynweekday=notEmpty(l)?l:null}else r.bynweekday=null;isPresent(r.byhour)?isNumber(r.byhour)&&(r.byhour=[r.byhour]):r.byhour=r.freq=4){d=0;l=u.yearlen+pymod(s-r.wkst,7)}else l=a-d;var c=Math.floor(l/7);var y=pymod(l,7);var f=Math.floor(c+y/4);for(var p=0;p0&&m<=f){var v=void 0;if(m>1){v=d+7*(m-1);d!==h&&(v-=7-h)}else v=d;for(var b=0;b<7;b++){u.wnomask[v]=1;v++;if(u.wdaymask[v]===r.wkst)break}}}if(includes(r.byweekno,1)){v=d+f*7;d!==h&&(v-=7-h);if(v=4){g=0;R=T+pymod(k-r.wkst,7)}else R=a-d;w=Math.floor(52+pymod(R,7)/4)}if(includes(r.byweekno,w))for(v=0;vi)return emitResult(e);if(w>=r){var g=rezoneIfNeeded(w,t);if(!e.accept(g))return emitResult(e);if(s){--s;if(!s)return emitResult(e)}}}}else for(b=f;bi)return emitResult(e);if(w>=r){g=rezoneIfNeeded(w,t);if(!e.accept(g))return emitResult(e);if(s){--s;if(!s)return emitResult(e)}}}}}if(t.interval===0)return emitResult(e);u.add(t,m);if(u.year>h)return emitResult(e);freqIsDailyOrGreater(n)||(d=l.gettimeset(n)(u.hour,u.minute,u.second,0));l.rebuild(u.year,u.month)}}function isFiltered(e,t,r){var n=r.bymonth,a=r.byweekno,i=r.byweekday,o=r.byeaster,s=r.bymonthday,u=r.bynmonthday,h=r.byyearday;return notEmpty(n)&&!includes(n,e.mmask[t])||notEmpty(a)&&!e.wnomask[t]||notEmpty(i)&&!includes(i,e.wdaymask[t])||notEmpty(e.nwdaymask)&&!e.nwdaymask[t]||o!==null&&!includes(e.eastermask,t)||(notEmpty(s)||notEmpty(u))&&!includes(s,e.mdaymask[t])&&!includes(u,e.nmdaymask[t])||notEmpty(h)&&(t=e.yearlen&&!includes(h,t+1-e.yearlen)&&!includes(h,-e.nextyearlen+t-e.yearlen))}function rezoneIfNeeded(e,t){return new g(e,t.tzid).rezonedDate()}function emitResult(e){return e.getValue()}function removeFilteredDays(e,t,r,n,a){var i=false;for(var o=t;o=Z.HOURLY&¬Empty(a)&&!includes(a,t.hour)||n>=Z.MINUTELY&¬Empty(i)&&!includes(i,t.minute)||n>=Z.SECONDLY&¬Empty(o)&&!includes(o,t.second)?[]:e.gettimeset(n)(t.hour,t.minute,t.second,t.millisecond)}var z={MO:new i(0),TU:new i(1),WE:new i(2),TH:new i(3),FR:new i(4),SA:new i(5),SU:new i(6)};var P={freq:m.YEARLY,dtstart:null,interval:1,wkst:z.MO,count:null,until:null,tzid:null,bysetpos:null,bymonth:null,bymonthday:null,bynmonthday:null,byyearday:null,byweekno:null,byweekday:null,bynweekday:null,byhour:null,byminute:null,bysecond:null,byeaster:null};var K=Object.keys(P); +/** + * + * @param {Options?} options - see + * - The only required option is `freq`, one of RRule.YEARLY, RRule.MONTHLY, ... + * @constructor + */var Z=function(){function RRule(e,t){e===void 0&&(e={});t===void 0&&(t=false);this._cache=t?null:new T;this.origOptions=initializeOptions$1(e);var r=parseOptions(e).parsedOptions;this.options=r}RRule.parseText=function(e,t){return parseText(e,t)};RRule.fromText=function(e,t){return fromText(e,t)};RRule.fromString=function(e){return new RRule(RRule.parseString(e)||void 0)};RRule.prototype._iter=function(e){return iter(e,this.options)};RRule.prototype._cacheGet=function(e,t){return!!this._cache&&this._cache._cacheGet(e,t)};RRule.prototype._cacheAdd=function(e,t,r){if(this._cache)return this._cache._cacheAdd(e,t,r)}; +/** + * @param {Function} iterator - optional function that will be called + * on each date that is added. It can return false + * to stop the iteration. + * @return Array containing all recurrences. + */RRule.prototype.all=function(e){if(e)return this._iter(new y("all",{},e));var t=this._cacheGet("all");if(t===false){t=this._iter(new c("all",{}));this._cacheAdd("all",t)}return t};RRule.prototype.between=function(e,t,r,n){r===void 0&&(r=false);if(!isValidDate(e)||!isValidDate(t))throw new Error("Invalid date passed in to RRule.between");var a={before:t,after:e,inc:r};if(n)return this._iter(new y("between",a,n));var i=this._cacheGet("between",a);if(i===false){i=this._iter(new c("between",a));this._cacheAdd("between",i,a)}return i};RRule.prototype.before=function(e,t){t===void 0&&(t=false);if(!isValidDate(e))throw new Error("Invalid date passed in to RRule.before");var r={dt:e,inc:t};var n=this._cacheGet("before",r);if(n===false){n=this._iter(new c("before",r));this._cacheAdd("before",n,r)}return n};RRule.prototype.after=function(e,t){t===void 0&&(t=false);if(!isValidDate(e))throw new Error("Invalid date passed in to RRule.after");var r={dt:e,inc:t};var n=this._cacheGet("after",r);if(n===false){n=this._iter(new c("after",r));this._cacheAdd("after",n,r)}return n};RRule.prototype.count=function(){return this.all().length};RRule.prototype.toString=function(){return optionsToString(this.origOptions)};RRule.prototype.toText=function(e,t,r){return toText(this,e,t,r)};RRule.prototype.isFullyConvertibleToText=function(){return b(this)};RRule.prototype.clone=function(){return new RRule(this.origOptions)};RRule.FREQUENCIES=["YEARLY","MONTHLY","WEEKLY","DAILY","HOURLY","MINUTELY","SECONDLY"];RRule.YEARLY=m.YEARLY;RRule.MONTHLY=m.MONTHLY;RRule.WEEKLY=m.WEEKLY;RRule.DAILY=m.DAILY;RRule.HOURLY=m.HOURLY;RRule.MINUTELY=m.MINUTELY;RRule.SECONDLY=m.SECONDLY;RRule.MO=z.MO;RRule.TU=z.TU;RRule.WE=z.WE;RRule.TH=z.TH;RRule.FR=z.FR;RRule.SA=z.SA;RRule.SU=z.SU;RRule.parseString=parseString;RRule.optionsToString=optionsToString;return RRule}();function iterSet(e,t,r,n,a,i){var o={};var s=e.accept;function evalExdate(e,t){r.forEach((function(r){r.between(e,t,true).forEach((function(e){o[Number(e)]=true}))}))}a.forEach((function(e){var t=new g(e,i).rezonedDate();o[Number(t)]=true}));e.accept=function(e){var t=Number(e);if(isNaN(t))return s.call(this,e);if(!o[t]){evalExdate(new Date(t-1),new Date(t+1));if(!o[t]){o[t]=true;return s.call(this,e)}}return true};if(e.method==="between"){evalExdate(e.args.after,e.args.before);e.accept=function(e){var t=Number(e);if(!o[t]){o[t]=true;return s.call(this,e)}return true}}for(var u=0;u1||a.length||i.length||o.length){var l=new G(h);l.dtstart(s);l.tzid(u||void 0);n.forEach((function(e){l.rrule(new Z(groomRruleOptions(e,s,u),h))}));a.forEach((function(e){l.rdate(e)}));i.forEach((function(e){l.exrule(new Z(groomRruleOptions(e,s,u),h))}));o.forEach((function(e){l.exdate(e)}));t.compatible&&t.dtstart&&l.rdate(s);return l}var d=n[0]||{};return new Z(groomRruleOptions(d,d.dtstart||t.dtstart||s,d.tzid||t.tzid||u),h)}function rrulestr(e,t){t===void 0&&(t={});return buildRule(e,initializeOptions(t))}function groomRruleOptions(e,r,n){return t(t({},e),{dtstart:r,tzid:n})}function initializeOptions(e){var r=[];var n=Object.keys(e);var a=Object.keys(B);n.forEach((function(e){includes(a,e)||r.push(e)}));if(r.length)throw new Error("Invalid options: "+r.join(", "));return t(t({},B),e)}function extractName(e){if(e.indexOf(":")===-1)return{name:"RRULE",value:e};var t=split(e,":",1),r=t[0],n=t[1];return{name:r,value:n}}function breakDownLine(e){var t=extractName(e),r=t.name,n=t.value;var a=r.split(";");if(!a)throw new Error("empty property name");return{name:a[0].toUpperCase(),parms:a.slice(1),value:n}}function splitIntoLines(e,t){t===void 0&&(t=false);e=e&&e.trim();if(!e)throw new Error("Invalid empty string");if(!t)return e.split(/\s/);var r=e.split("\n");var n=0;while(n0&&a[0]===" "){r[n-1]+=a.slice(1);r.splice(n,1)}else n+=1;else r.splice(n,1)}return r}function validateDateParm(e){e.forEach((function(e){if(!/(VALUE=DATE(-TIME)?)|(TZID=)/.test(e))throw new Error("unsupported RDATE/EXDATE parm: "+e)}))}function parseRDate(e,t){validateDateParm(t);return e.split(",").map((function(e){return untilStringToDate(e)}))}function createGetterSetter(e){var t=this;return function(r){r!==void 0&&(t["_".concat(e)]=r);if(t["_".concat(e)]!==void 0)return t["_".concat(e)];for(var n=0;n=0;c--)(o=e[c])&&(i=(a<3?o(i):a>3?o(t,r,i):o(t,r))||i);return a>3&&i&&Object.defineProperty(t,r,i),i}function __param(e,t){return function(r,n){t(r,n,e)}}function __esDecorate(e,t,r,n,o,a){function accept(e){if(e!==void 0&&typeof e!=="function")throw new TypeError("Function expected");return e}var i=n.kind,c=i==="getter"?"get":i==="setter"?"set":"value";var s=!t&&e?n.static?e:e.prototype:null;var u=t||(s?Object.getOwnPropertyDescriptor(s,n.name):{});var l,_=false;for(var f=r.length-1;f>=0;f--){var p={};for(var y in n)p[y]=y==="access"?{}:n[y];for(var y in n.access)p.access[y]=n.access[y];p.addInitializer=function(e){if(_)throw new TypeError("Cannot add initializers after decoration has completed");a.push(accept(e||null))};var d=(0,r[f])(i==="accessor"?{get:u.get,set:u.set}:u[c],p);if(i==="accessor"){if(d===void 0)continue;if(d===null||typeof d!=="object")throw new TypeError("Object expected");(l=accept(d.get))&&(u.get=l);(l=accept(d.set))&&(u.set=l);(l=accept(d.init))&&o.unshift(l)}else(l=accept(d))&&(i==="field"?o.unshift(l):u[c]=l)}s&&Object.defineProperty(s,n.name,u);_=true}function __runInitializers(e,t,r){var n=arguments.length>2;for(var o=0;o0&&o[o.length-1])&&(c[0]===6||c[0]===2)){a=0;continue}if(c[0]===3&&(!o||c[1]>o[0]&&c[1]=e.length&&(e=void 0);return{value:e&&e[n++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function __read(e,t){var r=typeof Symbol==="function"&&e[Symbol.iterator];if(!r)return e;var n,o,a=r.call(e),i=[];try{while((t===void 0||t-- >0)&&!(n=a.next()).done)i.push(n.value)}catch(e){o={error:e}}finally{try{n&&!n.done&&(r=a.return)&&r.call(a)}finally{if(o)throw o.error}}return i} +/** @deprecated */function __spread(){for(var e=[],t=0;t1||resume(e,t)}))};t&&(n[e]=t(n[e]))}}function resume(e,t){try{step(o[e](t))}catch(e){settle(a[0][3],e)}}function step(e){e.value instanceof __await?Promise.resolve(e.value.v).then(fulfill,reject):settle(a[0][2],e)}function fulfill(e){resume("next",e)}function reject(e){resume("throw",e)}function settle(e,t){(e(t),a.shift(),a.length)&&resume(a[0][0],a[0][1])}}function __asyncDelegator(e){var t,r;return t={},verb("next"),verb("throw",(function(e){throw e})),verb("return"),t[Symbol.iterator]=function(){return this},t;function verb(n,o){t[n]=e[n]?function(t){return(r=!r)?{value:__await(e[n](t)),done:false}:o?o(t):t}:o}}function __asyncValues(e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t,r=e[Symbol.asyncIterator];return r?r.call(e):(e=typeof __values==="function"?__values(e):e[Symbol.iterator](),t={},verb("next"),verb("throw"),verb("return"),t[Symbol.asyncIterator]=function(){return this},t);function verb(r){t[r]=e[r]&&function(t){return new Promise((function(n,o){t=e[r](t),settle(n,o,t.done,t.value)}))}}function settle(e,t,r,n){Promise.resolve(n).then((function(t){e({value:t,done:r})}),t)}}function __makeTemplateObject(e,t){Object.defineProperty?Object.defineProperty(e,"raw",{value:t}):e.raw=t;return e}var t=Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:true,value:t})}:function(e,t){e.default=t};var ownKeys=function(e){ownKeys=Object.getOwnPropertyNames||function(e){var t=[];for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[t.length]=r);return t};return ownKeys(e)};function __importStar(r){if(r&&r.__esModule)return r;var n={};if(r!=null)for(var o=ownKeys(r),a=0;a Date: Wed, 30 Apr 2025 17:59:21 -0600 Subject: [PATCH 20/94] Reworked deadlinable specs to use explicit, hardcoded values --- spec/models/concerns/deadlinable_spec.rb | 85 +++++++++++++++++++----- 1 file changed, 68 insertions(+), 17 deletions(-) diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index 4d56993c2f..1ad92b1eed 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -20,37 +20,87 @@ def deadline_day? let(:current_day) { Time.current } let(:schedule) { IceCube::Schedule.new(current_day) } - before do - dummy.deadline_day = 7 + shared_examples "doesn't validate absent field" do |field_name| + it "doesn't validate the #{field_name} field when it isn't present" do + dummy.public_send("#{field_name}=","") + expect(dummy).to be_valid + dummy.public_send("#{field_name}=",nil) + expect(dummy).to be_valid + end end describe "validations" do - it do - is_expected.to validate_numericality_of(:deadline_day) - .only_integer - .is_greater_than_or_equal_to(1) - .is_less_than_or_equal_to(28) - .allow_nil + it "validates the deadline_day field" do + dummy.deadline_day = nil + expect(dummy).to be_valid + dummy.deadline_day = 1 + expect(dummy).to be_valid + dummy.deadline_day = 28 + expect(dummy).to be_valid + dummy.deadline_day = 0.1 + expect(dummy).not_to be_valid + dummy.deadline_day = -1 + expect(dummy).not_to be_valid + dummy.deadline_day = 50 + expect(dummy).not_to be_valid end - it "validates the by_month_or_week field inclusion" do - is_expected.to validate_inclusion_of(:by_month_or_week).in_array(%w[day_of_month day_of_week]) + it "validates the by_month_or_week field" do + dummy.by_month_or_week = "day_of_month" + expect(dummy).to be_valid + dummy.by_month_or_week = "day_of_week" + expect(dummy).to be_valid + dummy.by_month_or_week = "other_string" + expect(dummy).not_to be_valid end - it "validates the day of week field inclusion" do - dummy.day_of_week = "0" - expect(dummy).to be_valid - dummy.day_of_week = "A" + include_examples "doesn't validate absent field", "by_month_or_week" + + it "validates the day_of_week field" do + (0..6).step(1) do |day| + dummy.day_of_week = day.to_s + expect(dummy).to be_valid + end + dummy.day_of_week = "-1" + expect(dummy).not_to be_valid + dummy.day_of_week = "7" + expect(dummy).not_to be_valid + dummy.day_of_week = "other_string" expect(dummy).not_to be_valid end - it "validates the by_month_or_week field inclusion" do - dummy.every_nth_day = "1" + include_examples "doesn't validate absent field", "day_of_week" + + it "validates the every_nth_day field" do + (1..4).step(1) do |n| + dummy.every_nth_day = n.to_s + expect(dummy).to be_valid + end + dummy.every_nth_day = "-1" expect(dummy).to be_valid - dummy.every_nth_day = "B" + dummy.every_nth_day = "6" + expect(dummy).not_to be_valid + dummy.every_nth_day = "other_string" + expect(dummy).not_to be_valid + end + + include_examples "doesn't validate absent field", "every_nth_day" + + it "validates the every_nth_month field" do + (1..12).step(1) do |n| + dummy.every_nth_month = n.to_s + expect(dummy).to be_valid + end + dummy.every_nth_month = "-1" + expect(dummy).not_to be_valid + dummy.every_nth_month = "24" + expect(dummy).not_to be_valid + dummy.every_nth_month = "other_string" expect(dummy).not_to be_valid end + include_examples "doesn't validate absent field", "every_nth_month" + it "validates that the reminder schedule's date fall within the range" do dummy.by_month_or_week = "day_of_month" dummy.day_of_month = 29 @@ -64,6 +114,7 @@ def deadline_day? it "validates that reminder day is not the same as deadline day" do dummy.by_month_or_week = "day_of_month" + dummy.deadline_day = 14 dummy.day_of_month = dummy.deadline_day expect(dummy).not_to be_valid From a03de3bad4e2530e06fe8fbe47e3caf1b7e871dc Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 1 May 2025 08:02:20 -0600 Subject: [PATCH 21/94] Updated check for same reminder and deadline days to only consider dayOfMonth and deadlineDay fields, as exact remidner date will shift when using byDayOfWeek --- app/javascript/controllers/deadline_day_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/controllers/deadline_day_controller.js b/app/javascript/controllers/deadline_day_controller.js index 0d58cbbfad..11918db37d 100644 --- a/app/javascript/controllers/deadline_day_controller.js +++ b/app/javascript/controllers/deadline_day_controller.js @@ -49,7 +49,7 @@ export default class extends Controller { deadline_date.setDate(parseInt(this.deadlineDayTarget.value)) } - if (reminder_date && deadline_date && reminder_date.getTime() === deadline_date.getTime()) { + if (this.byDayOfMonthTarget.checked && this.dayOfMonthTarget.value && this.deadlineDayTarget.value && this.dayOfMonthTarget.value === this.deadlineDayTarget.value) { $(this.reminderTextTarget).removeClass('text-muted').addClass('text-danger'); $(this.reminderTextTarget).text('Reminder day cannot be the same as deadline day.'); $(this.deadlineTextTarget).text(""); From eef571a20b9bc6ac1f1c7245e55a8bf2cb125e96 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 1 May 2025 08:18:55 -0600 Subject: [PATCH 22/94] Renamed validations to clairfy they validate the day_of_month field and not the reminder date more generally --- app/models/concerns/deadlinable.rb | 8 ++++---- spec/models/concerns/deadlinable_spec.rb | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index d6d53c8c39..cb3e4cf730 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -12,8 +12,8 @@ module Deadlinable attr_accessor :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month validates :deadline_day, numericality: {only_integer: true, less_than_or_equal_to: MAX_DAY_OF_MONTH, greater_than_or_equal_to: MIN_DAY_OF_MONTH, allow_nil: true} - validate :reminder_on_deadline_day?, if: -> { day_of_month.present? } - validate :reminder_is_within_range?, if: -> { day_of_month.present? } + validate :day_of_month_on_deadline_day?, if: -> { day_of_month.present? } + validate :day_of_month_is_within_range?, if: -> { day_of_month.present? } validates :by_month_or_week, inclusion: {in: %w[day_of_month day_of_week]}, if: -> { by_month_or_week.present? } validates :day_of_week, if: -> { day_of_week.present? }, inclusion: {in: %w[0 1 2 3 4 5 6]} validates :every_nth_day, if: -> { every_nth_day.present? }, inclusion: {in: %w[1 2 3 4 -1]} @@ -61,13 +61,13 @@ def get_values_from_reminder_schedule private - def reminder_on_deadline_day? + def day_of_month_on_deadline_day? if by_month_or_week == "day_of_month" && day_of_month.to_i == deadline_day errors.add(:day_of_month, "Reminder must not be the same as deadline date") end end - def reminder_is_within_range? + def day_of_month_is_within_range? # IceCube converts negative or zero days to valid days (e.g. -1 becomes the last day of the month, 0 becomes 1) # The minimum check should no longer be necessary, but keeping it in case IceCube changes if by_month_or_week == "day_of_month" && day_of_month.to_i < MIN_DAY_OF_MONTH || day_of_month.to_i > MAX_DAY_OF_MONTH diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index 1ad92b1eed..56a5272abb 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -101,7 +101,7 @@ def deadline_day? include_examples "doesn't validate absent field", "every_nth_month" - it "validates that the reminder schedule's date fall within the range" do + it "validates that day_of_month field falls within the range" do dummy.by_month_or_week = "day_of_month" dummy.day_of_month = 29 @@ -110,9 +110,10 @@ def deadline_day? dummy.day_of_month = -1 expect(dummy).not_to be_valid + expect(dummy.errors.added?(:day_of_month, "Reminder day must be between 1 and 28")).to be_truthy end - it "validates that reminder day is not the same as deadline day" do + it "validates that day_of_month field is not the same as deadline_day" do dummy.by_month_or_week = "day_of_month" dummy.deadline_day = 14 dummy.day_of_month = dummy.deadline_day From 515d55117abd83483e9246fc7987cf3880054e15 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 1 May 2025 13:06:47 -0600 Subject: [PATCH 23/94] Updated should_update_reminder_schedule and create_schedule to be public since they don't have side effects, tweaked should_update_reminder_schedule --- app/models/concerns/deadlinable.rb | 46 ++++++++++++++++++------------ 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index cb3e4cf730..0f8f66300b 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -59,32 +59,24 @@ def get_values_from_reminder_schedule self.every_nth_month = results[:every_nth_month] end - private - - def day_of_month_on_deadline_day? - if by_month_or_week == "day_of_month" && day_of_month.to_i == deadline_day - errors.add(:day_of_month, "Reminder must not be the same as deadline date") - end - end - - def day_of_month_is_within_range? - # IceCube converts negative or zero days to valid days (e.g. -1 becomes the last day of the month, 0 becomes 1) - # The minimum check should no longer be necessary, but keeping it in case IceCube changes - if by_month_or_week == "day_of_month" && day_of_month.to_i < MIN_DAY_OF_MONTH || day_of_month.to_i > MAX_DAY_OF_MONTH - errors.add(:day_of_month, "Reminder day must be between #{MIN_DAY_OF_MONTH} and #{MAX_DAY_OF_MONTH}") - end - end - def should_update_reminder_schedule if reminder_schedule.blank? return by_month_or_week.present? end sched = from_ical(reminder_schedule) - by_month_or_week != sched[:by_month_or_week].presence.to_s || - day_of_month != sched[:day_of_month].presence.to_s || - day_of_week != sched[:day_of_week].presence.to_s || + if by_month_or_week != sched[:by_month_or_week].presence.to_s + return true + end + if by_month_or_week == "day_of_month" + return day_of_month != sched[:day_of_month].presence.to_s || + every_nth_month != sched[:every_nth_month].presence.to_s + end + if by_month_or_week == "day_of_week" + return day_of_week != sched[:day_of_week].presence.to_s || every_nth_day != sched[:every_nth_day].presence.to_s || every_nth_month != sched[:every_nth_month].presence.to_s + end + return false end def create_schedule @@ -101,4 +93,20 @@ def create_schedule rescue nil end + + private + + def day_of_month_on_deadline_day? + if by_month_or_week == "day_of_month" && day_of_month.to_i == deadline_day + errors.add(:day_of_month, "Reminder must not be the same as deadline date") + end + end + + def day_of_month_is_within_range? + # IceCube converts negative or zero days to valid days (e.g. -1 becomes the last day of the month, 0 becomes 1) + # The minimum check should no longer be necessary, but keeping it in case IceCube changes + if by_month_or_week == "day_of_month" && day_of_month.to_i < MIN_DAY_OF_MONTH || day_of_month.to_i > MAX_DAY_OF_MONTH + errors.add(:day_of_month, "Reminder day must be between #{MIN_DAY_OF_MONTH} and #{MAX_DAY_OF_MONTH}") + end + end end From 99665dd2f6b0ada2df2dfce6b64d8833f62f037e Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 1 May 2025 13:10:52 -0600 Subject: [PATCH 24/94] Added specs for remaining deadlinable functions --- spec/models/concerns/deadlinable_spec.rb | 117 ++++++++++++++++++++++- 1 file changed, 114 insertions(+), 3 deletions(-) diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index 56a5272abb..fcfbde706d 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -103,12 +103,12 @@ def deadline_day? it "validates that day_of_month field falls within the range" do dummy.by_month_or_week = "day_of_month" - dummy.day_of_month = 29 + dummy.day_of_month = "29" expect(dummy).not_to be_valid expect(dummy.errors.added?(:day_of_month, "Reminder day must be between 1 and 28")).to be_truthy - dummy.day_of_month = -1 + dummy.day_of_month = "-1" expect(dummy).not_to be_valid expect(dummy.errors.added?(:day_of_month, "Reminder day must be between 1 and 28")).to be_truthy end @@ -116,10 +116,121 @@ def deadline_day? it "validates that day_of_month field is not the same as deadline_day" do dummy.by_month_or_week = "day_of_month" dummy.deadline_day = 14 - dummy.day_of_month = dummy.deadline_day + dummy.day_of_month = "14" expect(dummy).not_to be_valid expect(dummy.errors.added?(:day_of_month, "Reminder must not be the same as deadline date")).to be_truthy end end + + it "convert_to_reminder_schedule returns by month day schedule in ICAL format" do + travel_to Time.zone.local(2020, 10, 10) + expect(dummy.convert_to_reminder_schedule(10)).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" + end + + it "show_description returns textual description of rule in ICAL format" do + ical_schedule = "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" + expect(dummy.show_description(ical_schedule)).to eq "Monthly on the 10th day of the month" + end + + it "from_ical returns hash of fields from schedule in ICAL format" do + ical_schedule = "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" + expect(dummy.from_ical(ical_schedule)).to eq( + by_month_or_week: "day_of_month", + day_of_month: 10, + day_of_week: nil, + every_nth_day: nil, + every_nth_month: 1 + ) + end + + it "when reminder_schedule is blank should_update_reminder_schedule returns true if day_of_month is present, false otherwise" do + expect(dummy.should_update_reminder_schedule).to be_falsey + dummy.by_month_or_week = "day_of_month" + expect(dummy.should_update_reminder_schedule).to be_truthy + end + + context "with an existing schedule" do + before do + dummy.reminder_schedule = "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" + end + + it "should_update_reminder_schedule returns false if no fields differ" do + dummy.by_month_or_week = "day_of_month" + dummy.day_of_month = "10" + dummy.every_nth_month = "1" + expect(dummy.should_update_reminder_schedule).to be_falsey + end + + it "should_update_reminder_schedule returns true if fields differ" do + dummy.by_month_or_week = "day_of_month" + dummy.day_of_month = "15" + dummy.every_nth_month = "3" + expect(dummy.should_update_reminder_schedule).to be_truthy + end + + it "should_update_reminder_schedule return true if the by_month_or_week field differs" do + dummy.by_month_or_week = "day_of_week" + dummy.day_of_month = "10" + dummy.day_of_week = "0" + dummy.every_nth_day = "1" + dummy.every_nth_month = "1" + expect(dummy.should_update_reminder_schedule).to be_truthy + end + end + + context "by day of month" do + before do + travel_to Time.zone.local(2020, 10, 10) + dummy.by_month_or_week = "day_of_month" + end + + it "create_schedule returns schedule in ICAL format" do + dummy.day_of_month = "10" + dummy.every_nth_month = "1" + expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" + dummy.day_of_month = "15" + dummy.every_nth_month = "3" + expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15" + end + + it "create_schedule returns nil if needed fields are missing" do + dummy.day_of_month = "10" + expect(dummy.create_schedule).to eq nil + dummy.day_of_month = nil + dummy.every_nth_month = "1" + expect(dummy.create_schedule).to eq nil + end + end + + context "by day of week" do + before do + travel_to Time.zone.local(2020, 10, 10) + dummy.by_month_or_week = "day_of_week" + end + + it "create_schedule returns schedule in ICAL format" do + dummy.day_of_week = "0" + dummy.every_nth_day = "1" + dummy.every_nth_month = "1" + expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYDAY=1SU" + dummy.day_of_week = "3" + dummy.every_nth_day = "3" + dummy.every_nth_month = "1" + expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYDAY=3WE" + end + + it "create_schedule returns nil if needed fields are missing" do + dummy.day_of_week = "0" + dummy.every_nth_day = "1" + expect(dummy.create_schedule).to eq nil + dummy.every_nth_day = nil + dummy.every_nth_month = "1" + expect(dummy.create_schedule).to eq nil + dummy.day_of_week = nil + dummy.every_nth_day = "1" + expect(dummy.create_schedule).to eq nil + end + end + end From 05624c2c9a7ccb69a93904de8b1029e6791ab570 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Fri, 2 May 2025 15:15:32 -0600 Subject: [PATCH 25/94] Fixed specs using wrong type of params for creating schedules --- ...tch_partners_to_remind_now_service_spec.rb | 53 ++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb index f87a271246..1794a1afab 100644 --- a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb +++ b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb @@ -2,14 +2,19 @@ describe ".fetch" do subject { described_class.new.fetch } let(:current_day) { 14 } - before { travel_to(Time.zone.local(2022, 6, current_day, 1, 1, 1)) } + before { travel_to(Time.zone.local(2022, 6, current_day)) } context "when there is a partner" do let!(:partner) { create(:partner) } context "that has an organization with a global reminder & deadline" do context "that is for today" do before do - partner.organization.update(by_month_or_week: "day_of_month", day_of_month: current_day, deadline_day: current_day + 2) + partner.organization.update( + by_month_or_week: "day_of_month", + day_of_month: current_day.to_s, + every_nth_month: "1", + deadline_day: (current_day + 2).to_s + ) end it "should include that partner" do @@ -20,8 +25,13 @@ context "as matched by day of the week" do before do - partner.organization.update(by_month_or_week: "day_of_week", - day_of_week: 2, every_nth_day: 2, deadline_day: current_day + 2) + partner.organization.update( + by_month_or_week: "day_of_week", + day_of_week: "2", + every_nth_day: "2", + every_nth_month: "1", + deadline_day: (current_day + 2).to_s + ) end it "should include that partner" do schedule = IceCube::Schedule.from_ical partner.organization.reminder_schedule @@ -64,12 +74,21 @@ context "AND a partner group that does have them defined" do before do - partner_group = create(:partner_group, by_month_or_week: "day_of_month", - day_of_month: current_day, deadline_day: current_day + 2) + partner_group = create( + :partner_group, + by_month_or_week: "day_of_month", + day_of_month: (current_day).to_s, + every_nth_month: "1", + deadline_day: (current_day + 2).to_s + ) partner_group.partners << partner - partner.organization.update(by_month_or_week: "day_of_month", - day_of_month: current_day - 1, deadline_day: current_day + 2) + partner.organization.update( + by_month_or_week: "day_of_month", + day_of_month: (current_day - 1).to_s, + every_nth_month: "1", + deadline_day: (current_day + 2).to_s + ) end it "should remind based on the partner group instead of the organization level reminder" do @@ -96,8 +115,13 @@ context "and is a part of a partner group that does have them defined" do context "that is for today" do before do - partner_group = create(:partner_group, by_month_or_week: "day_of_month", - day_of_month: current_day, deadline_day: current_day + 2) + partner_group = create( + :partner_group, + by_month_or_week: "day_of_month", + day_of_month: (current_day).to_s, + every_nth_month: "1", + deadline_day: (current_day + 2).to_s + ) partner_group.partners << partner end @@ -118,8 +142,13 @@ context "that is not for today" do before do - partner_group = create(:partner_group, by_month_or_week: "day_of_month", - day_of_month: current_day - 1, deadline_day: current_day + 2) + partner_group = create( + :partner_group, + by_month_or_week: "day_of_month", + day_of_month: (current_day - 1).to_s, + every_nth_month: "1", + deadline_day: (current_day + 2).to_s + ) partner_group.partners << partner end From d3cb525e378621871158d2b6097fb10772b7df37 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Fri, 2 May 2025 15:18:38 -0600 Subject: [PATCH 26/94] Added spec to validate that if a partner has reminders disabled but is part of a group with reminders, it still receives a reminder --- .../fetch_partners_to_remind_now_service_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb index 1794a1afab..8ff4c9dea7 100644 --- a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb +++ b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb @@ -138,6 +138,16 @@ expect(subject).not_to include(partner) end end + + context "and has send_reminder=false" do + before do + partner.update(send_reminders: false) + end + + it "should include that partner" do + expect(subject).to include(partner) + end + end end context "that is not for today" do From 3bbb58890688ec530b91dda01d6ad33a14f7429b Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Fri, 2 May 2025 15:42:22 -0600 Subject: [PATCH 27/94] Added spec to verify that the partner group deadline day is prioritized over the organization deadline day --- spec/services/deadline_service_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/services/deadline_service_spec.rb b/spec/services/deadline_service_spec.rb index 26574ed70d..3e2b6f78cf 100644 --- a/spec/services/deadline_service_spec.rb +++ b/spec/services/deadline_service_spec.rb @@ -48,5 +48,13 @@ include_examples "calculates the next deadline" end + + context "the partner group is prioritized over the organization" do + before { organization[:deadline_day] = 20 } + + let(:expected_receiver) { partner_group } + + include_examples "calculates the next deadline" + end end end From deb3200c44785f1362d2ea08719dcca4bdc368cc Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 5 May 2025 08:37:01 -0600 Subject: [PATCH 28/94] Removed redundant checks on deadlinable fields from other models' specs --- spec/models/organization_spec.rb | 17 ----------------- spec/models/partner_group_spec.rb | 9 ++------- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 548a3aba2c..9edcaa4119 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -379,23 +379,6 @@ end end - describe 'reminder_schedule' do - it "cannot exceed 28 if by_month_or_week is day_of_month" do - expect(build(:organization, by_month_or_week: 'day_of_month', day_of_month: 28)).to be_valid - expect(build(:organization, by_month_or_week: 'day_of_month', day_of_month: 29)).to_not be_valid - expect(build(:organization, by_month_or_week: 'day_of_month', day_of_month: 0)).to_not be_valid - expect(build(:organization, by_month_or_week: 'day_of_month', day_of_month: -5)).to_not be_valid - end - end - describe 'deadline_day' do - it "can only contain numbers 1-28" do - expect(build(:organization, deadline_day: 28)).to be_valid - expect(build(:organization, deadline_day: 0)).to_not be_valid - expect(build(:organization, deadline_day: -5)).to_not be_valid - expect(build(:organization, deadline_day: 29)).to_not be_valid - end - end - describe 'earliest reporting year' do # re 2813 update annual report -- allowing an earliest reporting year will let us do system testing and staging for annual reports it 'is the organization created year if no associated data' do diff --git a/spec/models/partner_group_spec.rb b/spec/models/partner_group_spec.rb index 493e3b5520..76ef1330a9 100644 --- a/spec/models/partner_group_spec.rb +++ b/spec/models/partner_group_spec.rb @@ -28,18 +28,13 @@ end end + # While the deadlinable concern does it's own validation of the deadline_day field and there is the + # deadlinable_spec.rb for that, this constraint is defined in the db schema. describe 'deadline_day > 28' do it 'raises error if unmet' do expect { partner_group.update_column(:deadline_day, 29) }.to raise_error(ActiveRecord::StatementInvalid) end end - - describe 'reminder_schedule day > 28 and <=0' do - it 'raises error if unmet' do - expect { partner_group.update!(by_month_or_week: 'day_of_month', day_of_month: 29) }.to raise_error(ActiveRecord::RecordInvalid) - expect { partner_group.update!(by_month_or_week: 'day_of_month', day_of_month: -5) }.to raise_error(ActiveRecord::RecordInvalid) - end - end end # rubocop:enable Rails/SkipsModelValidations From 459800c394dbe8ce2923158ea46c0399b018dda0 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 5 May 2025 09:52:27 -0600 Subject: [PATCH 29/94] Fixed labels not being correctly hooked up to radio buttons --- app/views/shared/_deadline_day_fields.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index b5b7bc2cff..13a49aca35 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -15,13 +15,13 @@ 'day_of_month', label: 'Day of Month', data: { 'deadline-day-target': 'byDayOfMonth', 'action': 'deadline-day#monthOrWeekChanged deadline-day#sourceChange' } %> - <%= f.label :by_month_or_week, 'Day of Month' %> + <%= f.label :by_month_or_week_day_of_month, 'Day of Month' %>
<%= f.radio_button :by_month_or_week, 'day_of_week', label: 'Day of the Week', data: { 'deadline-day-target': 'byDayOfWeek', 'action': 'deadline-day#monthOrWeekChanged deadline-day#sourceChange' } %> - <%= f.label :by_month_or_week, 'Day of the Week' %> + <%= f.label :by_month_or_week_day_of_week, 'Day of the Week' %>
<%= f.input :day_of_month, as: :integer, wrapper: :input_group, label: 'Reminder date' do %> From 5f2cd85d8a472d1f1bb3b8b5e838201a457439d3 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 5 May 2025 13:31:47 -0600 Subject: [PATCH 30/94] Updated description of deadlines to specify they always happen after the reminder --- app/views/organizations/_details.html.erb | 2 +- app/views/partners/_partner_groups_table.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb index a37f95291d..98ff2ee2e2 100644 --- a/app/views/organizations/_details.html.erb +++ b/app/views/organizations/_details.html.erb @@ -187,7 +187,7 @@
Default deadline day (final day of month to submit Requests)

<%= fa_icon "calendar" %> - <%= @organization.deadline_day.blank? ? 'Not defined' : "The #{@organization.deadline_day.ordinalize} of each month" %> + <%= @organization.deadline_day.blank? ? 'Not defined' : "The #{@organization.deadline_day.ordinalize} after the reminder." %>

diff --git a/app/views/partners/_partner_groups_table.html.erb b/app/views/partners/_partner_groups_table.html.erb index 458e606e17..da4c9ea4ec 100644 --- a/app/views/partners/_partner_groups_table.html.erb +++ b/app/views/partners/_partner_groups_table.html.erb @@ -49,7 +49,7 @@ Reminder emails are sent <%= pg.show_description(pg.reminder_schedule) %>.
<% end %> - Deadlines are the <%= pg.deadline_day.ordinalize %> of every month. + Deadlines are the <%= pg.deadline_day.ordinalize %> after the reminder. <% else %> No <% end %> From 55849e6c95b704d2c9f9d145e6482414151d0c8c Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Tue, 6 May 2025 11:18:06 -0600 Subject: [PATCH 31/94] Updated deadline_day_controller to let user know when their input violates the between 1 and 28 constraint on dayOfMonth and deadlineDay --- .../controllers/deadline_day_controller.js | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/javascript/controllers/deadline_day_controller.js b/app/javascript/controllers/deadline_day_controller.js index 11918db37d..336817f9ef 100644 --- a/app/javascript/controllers/deadline_day_controller.js +++ b/app/javascript/controllers/deadline_day_controller.js @@ -49,14 +49,28 @@ export default class extends Controller { deadline_date.setDate(parseInt(this.deadlineDayTarget.value)) } - if (this.byDayOfMonthTarget.checked && this.dayOfMonthTarget.value && this.deadlineDayTarget.value && this.dayOfMonthTarget.value === this.deadlineDayTarget.value) { + if (this.byDayOfMonthTarget.checked && this.dayOfMonthTarget.value + && this.deadlineDayTarget.value && this.dayOfMonthTarget.value === this.deadlineDayTarget.value) { $(this.reminderTextTarget).removeClass('text-muted').addClass('text-danger'); $(this.reminderTextTarget).text('Reminder day cannot be the same as deadline day.'); $(this.deadlineTextTarget).text(""); } else { - $(this.reminderTextTarget).removeClass('text-danger').addClass('text-muted'); - $(this.reminderTextTarget).text(reminder_date ? `Your next reminder will be sent on ${reminder_date.toDateString()}.` : ""); - $(this.deadlineTextTarget).text(deadline_date ? `Your next deadline will be on ${deadline_date.toDateString()}.` : ""); + let dayOfMonth = parseInt(this.dayOfMonthTarget.value); + let deadlineDay = parseInt(this.deadlineDayTarget.value); + if (dayOfMonth < 1 || dayOfMonth > 28){ + $(this.reminderTextTarget).removeClass('text-muted').addClass('text-danger'); + $(this.reminderTextTarget).text("Reminder day must be between 1 and 28"); + } else { + $(this.reminderTextTarget).removeClass('text-danger').addClass('text-muted'); + $(this.reminderTextTarget).text(reminder_date ? `Your next reminder will be sent on ${reminder_date.toDateString()}.` : ""); + } + if (deadlineDay < 1 || deadlineDay > 28){ + $(this.deadlineTextTarget).removeClass('text-muted').addClass('text-danger'); + $(this.deadlineTextTarget).text("Deadline day must be between 1 and 28"); + } else { + $(this.deadlineTextTarget).removeClass('text-danger').addClass('text-muted'); + $(this.deadlineTextTarget).text(deadline_date ? `Your next deadline will be on ${deadline_date.toDateString()}.` : ""); + } } } From b81b144194007c043072266f74c35493afda5f57 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 7 May 2025 10:00:37 -0600 Subject: [PATCH 32/94] Added specs for shared deadline day form in the three pages where that form is used --- app/models/concerns/deadlinable.rb | 7 + .../deadline_day_fields_shared_example.rb | 146 ++++++++++++++++++ .../system/admin/organizations_system_spec.rb | 15 ++ spec/system/organization_system_spec.rb | 24 +-- spec/system/partner_system_spec.rb | 26 ++-- 5 files changed, 183 insertions(+), 35 deletions(-) create mode 100644 spec/support/deadline_day_fields_shared_example.rb diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index 0f8f66300b..b163d97923 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -7,6 +7,13 @@ module Deadlinable EVERY_NTH_MONTH_COLLECTION = [["Monthly", 1], ["Every 2 months", 2], ["Every 3 months", 3], ["Every 4 months", 4], ["Every 5 months", 5], ["Every 6 months", 6], ["Every 7 months", 7], ["Every 8 months", 8], ["Every 9 months", 9], ["Every 10 months", 10], ["Every 11 months", 11], ["Every 12 months", 12],].freeze + NTH_TO_WORD_MAP = { + 1 => "First", + 2 => "Second", + 3 => "Third", + 4 => "Fourth", + -1 => "Last" + }.freeze included do attr_accessor :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb new file mode 100644 index 0000000000..3449a78cac --- /dev/null +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -0,0 +1,146 @@ +RSpec.shared_examples_for "deadline and reminder form" do |form_prefix, save_button, post_form_submit| + + it "can set a reminder on a day of the month" do + choose "Day of Month" + fill_in "#{form_prefix}_day_of_month", with: 1 + click_on save_button + + if( post_form_submit ) + send(post_form_submit) + end + + expect(page).to have_content("Monthly on the 1st day of the month") + end + + it "can set a reminder on a day of the week" do + choose "Day of the Week" + select("First", from: "#{form_prefix}_every_nth_day" ) + select("Sunday", from: "#{form_prefix}_day_of_week" ) + click_on save_button + + if( post_form_submit ) + send(post_form_submit) + end + + expect(page).to have_content("Monthly on the 1st Sunday") + end + + it "can set a monthly frequency for reminders" do + select("Every 3 month", from: "How frequently should reminders be sent (e.g. \"monthly\", \"every 3 months\", etc.)?") + choose "Day of Month" + fill_in "#{form_prefix}_day_of_month", with: 1 + click_on save_button + + if( post_form_submit ) + send(post_form_submit) + end + + expect(page).to have_content("Every 3 months on the 1st day of the month") + end + + it "can set a default deadline day" do + fill_in "Default deadline day (final day of month to submit Requests)", with: 20 + click_on save_button + + if( post_form_submit ) + send(post_form_submit) + end + + expect(page).to have_content("20th after the reminder") + end + + it "warns the user if they enter the same reminder and deadline day" do + choose "Day of Month" + fill_in "#{form_prefix}_day_of_month", with: 15 + fill_in "Default deadline day (final day of month to submit Requests)", with: 15 + expect(page).to have_content("Reminder day cannot be the same as deadline day.") + expect(page).to_not have_content("Your next reminder will be sent on") + expect(page).to_not have_content("Your next deadline will be on") + end + + it "warns the user if the reminder day is outside the range of 1 to 28" do + choose "Day of Month" + fill_in "#{form_prefix}_day_of_month", with: "-1" + expect(page).to have_content("Reminder day must be between 1 and 28") + fill_in "#{form_prefix}_day_of_month", with: "20" + expect(page).to_not have_content("Reminder day must be between 1 and 28") + fill_in "#{form_prefix}_day_of_month", with: "100" + expect(page).to have_content("Reminder day must be between 1 and 28") + end + + it "warns the user if the deadline day is outside the range of 1 to 28" do + choose "Day of Month" + fill_in "Default deadline day (final day of month to submit Requests)", with: "-1" + expect(page).to have_content("Deadline day must be between 1 and 28") + fill_in "Default deadline day (final day of month to submit Requests)", with: "20" + expect(page).to_not have_content("Deadline day must be between 1 and 28") + fill_in "Default deadline day (final day of month to submit Requests)", with: "100" + expect(page).to have_content("Deadline day must be between 1 and 28") + end + + describe "calculates the reminder and deadline dates" do + + context "when the reminder is a day of the month" do + before do + choose "Day of Month" + @now = Time.zone.now + end + + it "prior to the current date" do + prior = @now - 1.days + fill_in "#{form_prefix}_day_of_month", with: prior.day + expect(page).to have_content("Your next reminder will be sent on") + expect(page).to have_content((prior + 1.month).strftime("%b %d %Y")) + end + + it "after the current date" do + after = @now + 1.days + fill_in "#{form_prefix}_day_of_month", with: after.day + expect(page).to have_content("Your next reminder will be sent on") + expect(page).to have_content((after).strftime("%b %d %Y")) + end + + it "and the reminder and deadline dates are different" do + fill_in "#{form_prefix}_day_of_month", with: @now.day + 1 + fill_in "Default deadline day (final day of month to submit Requests)", with: @now.day + 2 + expect(page).to have_content("Your next deadline will be on") + expect(page).to have_content((@now + 2.days).strftime("%b %d %Y")) + end + end + + context "when the reminder is a day of the week" do + before do + choose "Day of the Week" + @now = Time.zone.now + end + + it "prior to the current day" do + prior = @now - 1.days + every_nth_day = ((prior.day-1)/7) + 1 + if every_nth_day > 4 + every_nth_day = -1 + end + select(Deadlinable::NTH_TO_WORD_MAP[ every_nth_day ], from: "#{form_prefix}_every_nth_day" ) + select(Deadlinable::DAY_OF_WEEK_COLLECTION[ prior.wday ][0], from: "#{form_prefix}_day_of_week" ) + schedule = IceCube::Schedule.new() + schedule.add_recurrence_rule( IceCube::Rule.monthly().day_of_week(prior.wday => [every_nth_day]) ) + expect(page).to have_content("Your next reminder will be sent on") + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end + + it "after the current date" do + after = @now + 1.days + every_nth_day = ((after.day-1)/7) + 1 + if every_nth_day > 4 + every_nth_day = -1 + end + select(Deadlinable::NTH_TO_WORD_MAP[ every_nth_day ], from: "#{form_prefix}_every_nth_day" ) + select(Deadlinable::DAY_OF_WEEK_COLLECTION[ after.wday ][0], from: "#{form_prefix}_day_of_week" ) + schedule = IceCube::Schedule.new() + schedule.add_recurrence_rule( IceCube::Rule.monthly().day_of_week(after.wday => [every_nth_day]) ) + expect(page).to have_content("Your next reminder will be sent on") + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end + end + end +end \ No newline at end of file diff --git a/spec/system/admin/organizations_system_spec.rb b/spec/system/admin/organizations_system_spec.rb index c4c70e4b81..a895d8d681 100644 --- a/spec/system/admin/organizations_system_spec.rb +++ b/spec/system/admin/organizations_system_spec.rb @@ -39,6 +39,21 @@ expect(page).not_to have_content("Next ›") expect(page).not_to have_content("Last »") end + + describe "can edit organization details" do + before do + visit edit_admin_organization_path({ id: first_org.id }) + end + + def post_form_submit() + expect(page.find(".alert")).to have_content "Updated organization!" + within("tr.#{first_org.short_name}") do + first(:link, "View").click + end + end + + it_behaves_like "deadline and reminder form", "organization", "Save", :post_form_submit + end end context "while logged in as a super admin and there are enough organizations to trigger pagination" do diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index 3351fa19ed..6a1af8cf0b 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -84,29 +84,11 @@ expect(page.find(".alert")).to have_content "Updated" end - it "can set a reminder on a day of the month" do - choose "toggle-to-date" - fill_in "organization_day_of_month", with: 1 - click_on "Save" - expect(page.find(".alert")).to have_content "Updated your organization!" - expect(page).to have_content("Monthly on the 1st day of the month") - end - - it "can set a reminder on a day of the week" do - choose "toggle-to-week-day" - select("First", from: "organization_every_nth_day" ) - select("Sunday", from: "organization_day_of_week" ) - click_on "Save" - expect(page.find(".alert")).to have_content "Updated your organization!" - expect(page).to have_content("Monthly on the 1st Sunday") + def post_form_submit() + expect(page.find(".alert")).to have_content "Updated your organization!" end - it "can set a default deadline day" do - fill_in "Default deadline day (final day of month to submit Requests)", with: 20 - click_on "Save" - expect(page.find(".alert")).to have_content "Updated your organization!" - expect(page).to have_content("The 20th of each month") - end + it_behaves_like "deadline and reminder form", "organization", "Save", :post_form_submit it 'can select if the org repackages essentials' do choose('organization[repackage_essentials]', option: true) diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 75c8a1df36..7d61cf2831 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -630,7 +630,7 @@ # Opt in to sending deadline reminders check 'Yes' - choose 'toggle-to-date' + choose 'Day of Month' fill_in "partner_group_day_of_month", with: 1 fill_in "partner_group_deadline_day", with: 25 find_button('Add Partner Group').click @@ -669,22 +669,20 @@ assert page.has_content? item_category_2.name end - it 'should be able to edit a custom reminder schedule' do - visit partners_path + describe "editing a custom reminder schedule" do + before do + visit partners_path - click_on 'Groups' - assert page.has_content? existing_partner_group.name, wait: page_content_wait + click_on 'Groups' + assert page.has_content? existing_partner_group.name, wait: page_content_wait - click_on 'Edit' - # Opt in to sending deadline reminders - check 'Yes' - choose 'toggle-to-week-day', wait: page_content_wait - select "Second", from: "partner_group_every_nth_day" - select "Thursday", from: "partner_group_day_of_week" - fill_in "partner_group_deadline_day", with: 24 + click_on 'Edit' + # Opt in to sending deadline reminders + check 'Yes' + end - find_button('Update Partner Group').click - assert page.has_content? 'Monthly on the 2nd Thursday', wait: page_content_wait + it_behaves_like "deadline and reminder form", "partner_group", "Update Partner Group" + end end end From 6b1c4886fded408cf21206e940e705c9d7591c71 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 7 May 2025 12:11:20 -0600 Subject: [PATCH 33/94] Added spec to verify that the reminder and deadlines dates are consistent across the front and back end --- .../deadline_day_fields_shared_example.rb | 30 ++++++++++++++++++- .../system/admin/organizations_system_spec.rb | 8 ++++- spec/system/organization_system_spec.rb | 8 ++++- spec/system/partner_system_spec.rb | 8 +++-- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 3449a78cac..04404dcd0b 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -1,4 +1,4 @@ -RSpec.shared_examples_for "deadline and reminder form" do |form_prefix, save_button, post_form_submit| +RSpec.shared_examples_for "deadline and reminder form" do |form_prefix, save_button, reload_record, post_form_submit| it "can set a reminder on a day of the month" do choose "Day of Month" @@ -143,4 +143,32 @@ end end end + + it "the deadline day form's reminder and deadline dates are consistent with the dates calculated by the FetchPartnersToRemindNowService and DeadlineService" do + choose "Day of Month" + select("Every 2 month", from: "How frequently should reminders be sent (e.g. \"monthly\", \"every 3 months\", etc.)?") + fill_in "#{form_prefix}_day_of_month", with: 14 + fill_in "Default deadline day (final day of month to submit Requests)", with: 21 + + reminder_text = find('small[data-deadline-day-target="reminderText"]').text + reminder_text.slice!("Your next reminder will be sent on ") + reminder_text.slice!(".") + shown_recurrence_date = Time.zone.strptime(reminder_text, "%a %b %d %Y") + + deadline_text = find('small[data-deadline-day-target="deadlineText"]').text + deadline_text.slice!("Your next deadline will be on ") + deadline_text.slice!(".") + shown_deadline_date = Time.zone.strptime(deadline_text, "%a %b %d %Y") + + click_on save_button + send(reload_record) + + expect(Partners::FetchPartnersToRemindNowService.new.fetch()).to_not include(partner) + + travel_to shown_recurrence_date + + expect(Partners::FetchPartnersToRemindNowService.new.fetch()).to include(partner) + expect(DeadlineService.new(partner: partner).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date + end + end \ No newline at end of file diff --git a/spec/system/admin/organizations_system_spec.rb b/spec/system/admin/organizations_system_spec.rb index a895d8d681..d3bdf4507f 100644 --- a/spec/system/admin/organizations_system_spec.rb +++ b/spec/system/admin/organizations_system_spec.rb @@ -41,10 +41,16 @@ end describe "can edit organization details" do + let(:partner) { create(:partner, organization: first_org) } + before do visit edit_admin_organization_path({ id: first_org.id }) end + def reload_record() + first_org.reload + end + def post_form_submit() expect(page.find(".alert")).to have_content "Updated organization!" within("tr.#{first_org.short_name}") do @@ -52,7 +58,7 @@ def post_form_submit() end end - it_behaves_like "deadline and reminder form", "organization", "Save", :post_form_submit + it_behaves_like "deadline and reminder form", "organization", "Save", :reload_record, :post_form_submit end end diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index 6a1af8cf0b..8f1da2ef4a 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -70,6 +70,8 @@ end describe "Editing the organization" do + let(:partner) { create(:partner, organization: organization) } + before do visit edit_organization_path end @@ -84,11 +86,15 @@ expect(page.find(".alert")).to have_content "Updated" end + def reload_record() + organization.reload + end + def post_form_submit() expect(page.find(".alert")).to have_content "Updated your organization!" end - it_behaves_like "deadline and reminder form", "organization", "Save", :post_form_submit + it_behaves_like "deadline and reminder form", "organization", "Save", :reload_record, :post_form_submit it 'can select if the org repackages essentials' do choose('organization[repackage_essentials]', option: true) diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 7d61cf2831..6805627be6 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -671,6 +671,7 @@ describe "editing a custom reminder schedule" do before do + partner.update!(partner_group: existing_partner_group) visit partners_path click_on 'Groups' @@ -681,8 +682,11 @@ check 'Yes' end - it_behaves_like "deadline and reminder form", "partner_group", "Update Partner Group" - + def reload_record() + existing_partner_group.reload + end + + it_behaves_like "deadline and reminder form", "partner_group", "Update Partner Group", :reload_record end end end From b7b632eb7237f4ddf3dfa238021e4bde24dcc625 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 7 May 2025 12:47:48 -0600 Subject: [PATCH 34/94] Fixed spec refering to old radio button id --- spec/system/admin/organizations_system_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/system/admin/organizations_system_spec.rb b/spec/system/admin/organizations_system_spec.rb index d3bdf4507f..26fe8c4387 100644 --- a/spec/system/admin/organizations_system_spec.rb +++ b/spec/system/admin/organizations_system_spec.rb @@ -138,7 +138,7 @@ def post_form_submit() fill_in "organization_user_name", with: admin_user_params[:name] fill_in "organization_user_email", with: admin_user_params[:email] - choose 'toggle-to-date' + choose 'Day of Month' fill_in "organization_day_of_month", with: 1 click_on "Save" From 1febd8971113663ac18be7b1357fd21aae790179 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 8 May 2025 12:22:04 -0600 Subject: [PATCH 35/94] Changes made by linter --- app/models/concerns/deadlinable.rb | 12 ++--- .../shared/_deadline_day_fields.html.erb | 1 - spec/models/concerns/deadlinable_spec.rb | 15 +++--- spec/models/partner_group_spec.rb | 2 +- spec/services/deadline_service_spec.rb | 2 +- ...tch_partners_to_remind_now_service_spec.rb | 6 +-- .../deadline_day_fields_shared_example.rb | 53 +++++++++---------- .../system/admin/organizations_system_spec.rb | 8 +-- spec/system/organization_system_spec.rb | 6 +-- spec/system/partner_system_spec.rb | 2 +- 10 files changed, 51 insertions(+), 56 deletions(-) diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index b163d97923..de49115e9e 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -6,7 +6,7 @@ module Deadlinable DAY_OF_WEEK_COLLECTION = [["Sunday", 0], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]].freeze EVERY_NTH_MONTH_COLLECTION = [["Monthly", 1], ["Every 2 months", 2], ["Every 3 months", 3], ["Every 4 months", 4], ["Every 5 months", 5], ["Every 6 months", 6], ["Every 7 months", 7], ["Every 8 months", 8], ["Every 9 months", 9], ["Every 10 months", 10], ["Every 11 months", 11], - ["Every 12 months", 12],].freeze + ["Every 12 months", 12]].freeze NTH_TO_WORD_MAP = { 1 => "First", 2 => "Second", @@ -24,7 +24,7 @@ module Deadlinable validates :by_month_or_week, inclusion: {in: %w[day_of_month day_of_week]}, if: -> { by_month_or_week.present? } validates :day_of_week, if: -> { day_of_week.present? }, inclusion: {in: %w[0 1 2 3 4 5 6]} validates :every_nth_day, if: -> { every_nth_day.present? }, inclusion: {in: %w[1 2 3 4 -1]} - validates :every_nth_month, if: -> { every_nth_month.present? }, inclusion: {in: EVERY_NTH_MONTH_COLLECTION.map{|ar| ar[1].to_s} } + validates :every_nth_month, if: -> { every_nth_month.present? }, inclusion: {in: EVERY_NTH_MONTH_COLLECTION.map { |ar| ar[1].to_s }} end def convert_to_reminder_schedule(day) @@ -76,14 +76,14 @@ def should_update_reminder_schedule end if by_month_or_week == "day_of_month" return day_of_month != sched[:day_of_month].presence.to_s || - every_nth_month != sched[:every_nth_month].presence.to_s + every_nth_month != sched[:every_nth_month].presence.to_s end if by_month_or_week == "day_of_week" return day_of_week != sched[:day_of_week].presence.to_s || - every_nth_day != sched[:every_nth_day].presence.to_s || - every_nth_month != sched[:every_nth_month].presence.to_s + every_nth_day != sched[:every_nth_day].presence.to_s || + every_nth_month != sched[:every_nth_month].presence.to_s end - return false + false end def create_schedule diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index 13a49aca35..d040c05dc2 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -55,7 +55,6 @@
- <%= f.input :deadline_day, wrapper: :input_group, wrapper_html: { min: 0, max: 28 }, label: 'Default deadline day (final day of month to submit Requests)' do %> <%= f.number_field :deadline_day, diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index fcfbde706d..af8b2d09c0 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -22,9 +22,9 @@ def deadline_day? shared_examples "doesn't validate absent field" do |field_name| it "doesn't validate the #{field_name} field when it isn't present" do - dummy.public_send("#{field_name}=","") + dummy.public_send(:"#{field_name}=", "") expect(dummy).to be_valid - dummy.public_send("#{field_name}=",nil) + dummy.public_send(:"#{field_name}=", nil) expect(dummy).to be_valid end end @@ -145,7 +145,7 @@ def deadline_day? end it "when reminder_schedule is blank should_update_reminder_schedule returns true if day_of_month is present, false otherwise" do - expect(dummy.should_update_reminder_schedule).to be_falsey + expect(dummy.should_update_reminder_schedule).to be_falsey dummy.by_month_or_week = "day_of_month" expect(dummy.should_update_reminder_schedule).to be_truthy end @@ -159,14 +159,14 @@ def deadline_day? dummy.by_month_or_week = "day_of_month" dummy.day_of_month = "10" dummy.every_nth_month = "1" - expect(dummy.should_update_reminder_schedule).to be_falsey + expect(dummy.should_update_reminder_schedule).to be_falsey end it "should_update_reminder_schedule returns true if fields differ" do dummy.by_month_or_week = "day_of_month" dummy.day_of_month = "15" dummy.every_nth_month = "3" - expect(dummy.should_update_reminder_schedule).to be_truthy + expect(dummy.should_update_reminder_schedule).to be_truthy end it "should_update_reminder_schedule return true if the by_month_or_week field differs" do @@ -175,7 +175,7 @@ def deadline_day? dummy.day_of_week = "0" dummy.every_nth_day = "1" dummy.every_nth_month = "1" - expect(dummy.should_update_reminder_schedule).to be_truthy + expect(dummy.should_update_reminder_schedule).to be_truthy end end @@ -225,12 +225,11 @@ def deadline_day? dummy.every_nth_day = "1" expect(dummy.create_schedule).to eq nil dummy.every_nth_day = nil - dummy.every_nth_month = "1" + dummy.every_nth_month = "1" expect(dummy.create_schedule).to eq nil dummy.day_of_week = nil dummy.every_nth_day = "1" expect(dummy.create_schedule).to eq nil end end - end diff --git a/spec/models/partner_group_spec.rb b/spec/models/partner_group_spec.rb index 76ef1330a9..195bed6e2c 100644 --- a/spec/models/partner_group_spec.rb +++ b/spec/models/partner_group_spec.rb @@ -29,7 +29,7 @@ end # While the deadlinable concern does it's own validation of the deadline_day field and there is the - # deadlinable_spec.rb for that, this constraint is defined in the db schema. + # deadlinable_spec.rb for that, this constraint is defined in the db schema. describe 'deadline_day > 28' do it 'raises error if unmet' do expect { partner_group.update_column(:deadline_day, 29) }.to raise_error(ActiveRecord::StatementInvalid) diff --git a/spec/services/deadline_service_spec.rb b/spec/services/deadline_service_spec.rb index 3e2b6f78cf..bb1ed14ab8 100644 --- a/spec/services/deadline_service_spec.rb +++ b/spec/services/deadline_service_spec.rb @@ -53,7 +53,7 @@ before { organization[:deadline_day] = 20 } let(:expected_receiver) { partner_group } - + include_examples "calculates the next deadline" end end diff --git a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb index 8ff4c9dea7..20ac9827e6 100644 --- a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb +++ b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb @@ -77,7 +77,7 @@ partner_group = create( :partner_group, by_month_or_week: "day_of_month", - day_of_month: (current_day).to_s, + day_of_month: current_day.to_s, every_nth_month: "1", deadline_day: (current_day + 2).to_s ) @@ -118,7 +118,7 @@ partner_group = create( :partner_group, by_month_or_week: "day_of_month", - day_of_month: (current_day).to_s, + day_of_month: current_day.to_s, every_nth_month: "1", deadline_day: (current_day + 2).to_s ) @@ -143,7 +143,7 @@ before do partner.update(send_reminders: false) end - + it "should include that partner" do expect(subject).to include(partner) end diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 04404dcd0b..11075e4365 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -1,11 +1,10 @@ RSpec.shared_examples_for "deadline and reminder form" do |form_prefix, save_button, reload_record, post_form_submit| - it "can set a reminder on a day of the month" do choose "Day of Month" fill_in "#{form_prefix}_day_of_month", with: 1 click_on save_button - if( post_form_submit ) + if post_form_submit send(post_form_submit) end @@ -14,11 +13,11 @@ it "can set a reminder on a day of the week" do choose "Day of the Week" - select("First", from: "#{form_prefix}_every_nth_day" ) - select("Sunday", from: "#{form_prefix}_day_of_week" ) + select("First", from: "#{form_prefix}_every_nth_day") + select("Sunday", from: "#{form_prefix}_day_of_week") click_on save_button - if( post_form_submit ) + if post_form_submit send(post_form_submit) end @@ -31,7 +30,7 @@ fill_in "#{form_prefix}_day_of_month", with: 1 click_on save_button - if( post_form_submit ) + if post_form_submit send(post_form_submit) end @@ -42,7 +41,7 @@ fill_in "Default deadline day (final day of month to submit Requests)", with: 20 click_on save_button - if( post_form_submit ) + if post_form_submit send(post_form_submit) end @@ -79,7 +78,6 @@ end describe "calculates the reminder and deadline dates" do - context "when the reminder is a day of the month" do before do choose "Day of Month" @@ -87,17 +85,17 @@ end it "prior to the current date" do - prior = @now - 1.days + prior = @now - 1.day fill_in "#{form_prefix}_day_of_month", with: prior.day expect(page).to have_content("Your next reminder will be sent on") expect(page).to have_content((prior + 1.month).strftime("%b %d %Y")) end it "after the current date" do - after = @now + 1.days + after = @now + 1.day fill_in "#{form_prefix}_day_of_month", with: after.day expect(page).to have_content("Your next reminder will be sent on") - expect(page).to have_content((after).strftime("%b %d %Y")) + expect(page).to have_content(after.strftime("%b %d %Y")) end it "and the reminder and deadline dates are different" do @@ -115,29 +113,29 @@ end it "prior to the current day" do - prior = @now - 1.days - every_nth_day = ((prior.day-1)/7) + 1 + prior = @now - 1.day + every_nth_day = ((prior.day - 1) / 7) + 1 if every_nth_day > 4 every_nth_day = -1 end - select(Deadlinable::NTH_TO_WORD_MAP[ every_nth_day ], from: "#{form_prefix}_every_nth_day" ) - select(Deadlinable::DAY_OF_WEEK_COLLECTION[ prior.wday ][0], from: "#{form_prefix}_day_of_week" ) - schedule = IceCube::Schedule.new() - schedule.add_recurrence_rule( IceCube::Rule.monthly().day_of_week(prior.wday => [every_nth_day]) ) + select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") + select(Deadlinable::DAY_OF_WEEK_COLLECTION[prior.wday][0], from: "#{form_prefix}_day_of_week") + schedule = IceCube::Schedule.new + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(prior.wday => [every_nth_day])) expect(page).to have_content("Your next reminder will be sent on") expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "after the current date" do - after = @now + 1.days - every_nth_day = ((after.day-1)/7) + 1 + after = @now + 1.day + every_nth_day = ((after.day - 1) / 7) + 1 if every_nth_day > 4 every_nth_day = -1 end - select(Deadlinable::NTH_TO_WORD_MAP[ every_nth_day ], from: "#{form_prefix}_every_nth_day" ) - select(Deadlinable::DAY_OF_WEEK_COLLECTION[ after.wday ][0], from: "#{form_prefix}_day_of_week" ) - schedule = IceCube::Schedule.new() - schedule.add_recurrence_rule( IceCube::Rule.monthly().day_of_week(after.wday => [every_nth_day]) ) + select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") + select(Deadlinable::DAY_OF_WEEK_COLLECTION[after.wday][0], from: "#{form_prefix}_day_of_week") + schedule = IceCube::Schedule.new + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(after.wday => [every_nth_day])) expect(page).to have_content("Your next reminder will be sent on") expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end @@ -145,7 +143,7 @@ end it "the deadline day form's reminder and deadline dates are consistent with the dates calculated by the FetchPartnersToRemindNowService and DeadlineService" do - choose "Day of Month" + choose "Day of Month" select("Every 2 month", from: "How frequently should reminders be sent (e.g. \"monthly\", \"every 3 months\", etc.)?") fill_in "#{form_prefix}_day_of_month", with: 14 fill_in "Default deadline day (final day of month to submit Requests)", with: 21 @@ -163,12 +161,11 @@ click_on save_button send(reload_record) - expect(Partners::FetchPartnersToRemindNowService.new.fetch()).to_not include(partner) + expect(Partners::FetchPartnersToRemindNowService.new.fetch).to_not include(partner) travel_to shown_recurrence_date - expect(Partners::FetchPartnersToRemindNowService.new.fetch()).to include(partner) + expect(Partners::FetchPartnersToRemindNowService.new.fetch).to include(partner) expect(DeadlineService.new(partner: partner).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date end - -end \ No newline at end of file +end diff --git a/spec/system/admin/organizations_system_spec.rb b/spec/system/admin/organizations_system_spec.rb index 26fe8c4387..57f8316e77 100644 --- a/spec/system/admin/organizations_system_spec.rb +++ b/spec/system/admin/organizations_system_spec.rb @@ -44,21 +44,21 @@ let(:partner) { create(:partner, organization: first_org) } before do - visit edit_admin_organization_path({ id: first_org.id }) + visit edit_admin_organization_path({id: first_org.id}) end - def reload_record() + def reload_record first_org.reload end - def post_form_submit() + def post_form_submit expect(page.find(".alert")).to have_content "Updated organization!" within("tr.#{first_org.short_name}") do first(:link, "View").click end end - it_behaves_like "deadline and reminder form", "organization", "Save", :reload_record, :post_form_submit + it_behaves_like "deadline and reminder form", "organization", "Save", :reload_record, :post_form_submit end end diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index 8f1da2ef4a..2d3dec9988 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -86,12 +86,12 @@ expect(page.find(".alert")).to have_content "Updated" end - def reload_record() + def reload_record organization.reload end - def post_form_submit() - expect(page.find(".alert")).to have_content "Updated your organization!" + def post_form_submit + expect(page.find(".alert")).to have_content "Updated your organization!" end it_behaves_like "deadline and reminder form", "organization", "Save", :reload_record, :post_form_submit diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 6805627be6..032036cd28 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -682,7 +682,7 @@ check 'Yes' end - def reload_record() + def reload_record existing_partner_group.reload end From 7e9dd92eae43ab132131959e09d5936fbb05e1bf Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 8 May 2025 12:35:52 -0600 Subject: [PATCH 36/94] Updated and modified documentation to explain how reminder schedules work --- .../bank/getting_started_customization.md | 15 ++++++++---- .../partners/partners_reminder_flowchart.png | Bin 0 -> 227300 bytes docs/user_guide/bank/pm_adding_a_partner.md | 7 +++++- docs/user_guide/bank/pm_partner_groups.md | 6 ++++- docs/user_guide/bank/pm_partner_reminders.md | 22 ++++++++++++++++++ 5 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 docs/user_guide/bank/images/partners/partners_reminder_flowchart.png create mode 100644 docs/user_guide/bank/pm_partner_reminders.md diff --git a/docs/user_guide/bank/getting_started_customization.md b/docs/user_guide/bank/getting_started_customization.md index 1e036b86f6..28bb5166f8 100644 --- a/docs/user_guide/bank/getting_started_customization.md +++ b/docs/user_guide/bank/getting_started_customization.md @@ -134,9 +134,12 @@ This is a special topic that has its own guide page [here](special_custom_units. ## Other emails -#### Default reminder day (day of month an email reminder to submit Requests is sent to Partners) -At this point, we send those emails once a month on the day of the month you indicate here. -If you do not pick a day, no reminder emails are sent. +#### Send reminders on a specific day of the month (e.g. "the 5th") or a day of the week (eg "the first Tuesday")? +You may configure how frequently you would like reminders to be sent to your Partners. + +This works in conjunction with the reminder configuration set on a partner group level (see [Partner Groups](pm_partner_groups.md)) and the partner specific configuration (see [Adding a single Partner](pm_adding_a_partner.md)). + +For a full description of how the reminder schedules work, and how the different configurations interact, see [Partner Reminder Emails](pm_partner_reminders.md). The text of this email will be: @@ -159,7 +162,11 @@ if you have any questions about this! #### Default deadline day (final day of month to submit Requests) -This day will be included in the reminder email message. +This is the day which will be included in the reminder email message. + +It is assumed that the deadline day always occurs after the day the reminder is sent, and in cases where the deadline date specified is in the past, the deadline will be set to the next month. + +As an example, the reminder is set to be every month on the 14th, and the deadline day is set to be the 7th. On January 14th, a reminder will be sent out and, since the 7th is in the past, the deadline date will be listed as February 7th. #### Additional text for reminder email If present, this text will be included in the reminder email after the default text. In the above email template, this additional message will replace `[Additional text for reminder email (see below)]`. diff --git a/docs/user_guide/bank/images/partners/partners_reminder_flowchart.png b/docs/user_guide/bank/images/partners/partners_reminder_flowchart.png new file mode 100644 index 0000000000000000000000000000000000000000..2ca1b8c61097858f794013633b91a2053f740f00 GIT binary patch literal 227300 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∋V;h7XZV%UMJDBXonPuiex3aaT#HdJoi#faM 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 E8!yy!XaE2J literal 0 HcmV?d00001 diff --git a/docs/user_guide/bank/pm_adding_a_partner.md b/docs/user_guide/bank/pm_adding_a_partner.md index 83a40b6491..04b80b11fe 100644 --- a/docs/user_guide/bank/pm_adding_a_partner.md +++ b/docs/user_guide/bank/pm_adding_a_partner.md @@ -16,7 +16,12 @@ What Partner Group, if any, does the Partner belong to. Partner Groups are very [!NOTE] Partners who belong to Partner Groups can *only* choose the Items that have the Item Categories specified for their Partner Group. ### Do you want this Partner to receive emails for Distributions and Reminders from the system? -See also the questions about customizing reminders in [your organization](getting_started_customization.md) +If enabled, the Partner may receive reminders if your organization, or the Partner's Partner Group is configured with a reminder schedule. + +This works in conjunction with the reminder configuration set on an organization level (see [Getting Started - Customization](getting_started_customization.md)) and partner group level (see [Adding a single Partner](pm_adding_a_partner.md)). + +For a full description of how the reminder schedules work, and how the different configurations interact, see [Partner Reminder Emails](pm_partner_reminders.md). + ### Quota This is an information-only quota -- it is meant to be total Items per request. We give a friendly "are you sure you wanted to order that much?" kind of warning on the partner's confirmation screen if they over order, but there is *no* actual enforcement. If entered, this value is also displayed in your view of the requests. diff --git a/docs/user_guide/bank/pm_partner_groups.md b/docs/user_guide/bank/pm_partner_groups.md index b81e8cd503..68d6b5bc78 100644 --- a/docs/user_guide/bank/pm_partner_groups.md +++ b/docs/user_guide/bank/pm_partner_groups.md @@ -30,7 +30,11 @@ they will not be able to request any Items that are not in a category For clarity - if you do not choose any categories, they will not be able to choose any items, so if you are using Partner Groups, you have to use Item Categories. ### Do you want to send deadline reminders to them every month? -This works in conjunction with "Reminder day" and "Deadline day", which is set on an organization level (see [Getting Started - Customization](getting_started_customization.md)) +If enabled, you may configure how frequently you would like reminders to be sent to Partners who are part of this Partner Group. + +This works in conjunction with the reminder configuration set on an organization level (see [Getting Started - Customization](getting_started_customization.md)) and the Partner specific configuration (see [Adding a single Partner](pm_adding_a_partner.md)). + +For a full description of how the reminder schedules work, and how the different configurations interact, see [Partner Reminder Emails](pm_partner_reminders.md). # What if a partner isn't in a group? If a Partner is not in a Partner Group, they can request any item that is visible to partners. diff --git a/docs/user_guide/bank/pm_partner_reminders.md b/docs/user_guide/bank/pm_partner_reminders.md new file mode 100644 index 0000000000..579c5e5f64 --- /dev/null +++ b/docs/user_guide/bank/pm_partner_reminders.md @@ -0,0 +1,22 @@ +# Partner Reminder Emails +You may configure a reminder schedule on an organization and/or Partner Group level. Partners who are covered by these categories, and who individually have reminders enabled, will receive an email based on the schedule, reminding them of the deadline for submitting requests. + +You may configure the monthly frequency of reminders and the date of the month or weekday of the month they are sent. You may also configure the deadline date included in the email. + +As you fill out the form, it should show you a preview of the next time the reminder will be sent, and the deadline date that will be included in the email. + +## Default deadline day (final day of month to submit Requests) +This is the day which will be included in the reminder email message. + +It is assumed that the deadline day always occurs after the day the reminder is sent, and in cases where the deadline date specified is in the past, the deadline will be set to the next month. + +As an example, the reminder is set to be every month on the 14th, and the deadline day is set to be the 7th. On January 14th, a reminder will be sent out and, since the 7th is in the past, the deadline date will be listed as February 7th. + +## Reminder Schedule Priority +The logic which selects which reminder schedule, if any, to use to remind a partner is described below: + +![](images/partners/partners_reminder_flowchart.png) + +Some key points: +- If a Partner is part of a Partner Group that has a reminder schedule configured, they will receive reminders based on the Partner Group's reminder schedule even if that partner has reminders disabled. +- The reminder schedule and deadline day of a Partner Group will always supersede those set on the Organizational level. \ No newline at end of file From 1b04251332f20903f9281f568dfcb8a7f47eef3e Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 8 May 2025 14:46:42 -0600 Subject: [PATCH 37/94] Fixed PartnerGroup not checking if reminder_schedule needs to be updates, added comments explaining need for the check --- app/models/organization.rb | 2 ++ app/models/partner_group.rb | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/models/organization.rb b/app/models/organization.rb index 37f16aabd5..3bf09a86bb 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -94,6 +94,8 @@ def upcoming end before_save do + # To avoid constantly changing the start date of the reminder_schedule, only update the schedule if something has actually + # changed. if should_update_reminder_schedule self.reminder_schedule = create_schedule end diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index a4998e0076..2edca92f1a 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -20,7 +20,11 @@ class PartnerGroup < ApplicationRecord has_and_belongs_to_many :item_categories before_save do - self.reminder_schedule = create_schedule + # To avoid constantly changing the start date of the reminder_schedule, only update the schedule if something has actually + # changed. + if should_update_reminder_schedule + self.reminder_schedule = create_schedule + end end validates :name, presence: true, uniqueness: { scope: :organization } From 6128bba12534d9bb6f2872e2c6a777051c98c151 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Fri, 9 May 2025 09:18:55 -0600 Subject: [PATCH 38/94] Forgot to include Gemfile.lock --- Gemfile.lock | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 62ff38478a..39828a4c04 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -268,7 +268,6 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) - ffi (1.17.2) ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) @@ -381,7 +380,6 @@ GEM memory_profiler (1.1.0) method_source (1.1.0) mini_mime (1.1.5) - mini_portile2 (2.8.8) minitest (5.25.5) monetize (1.12.0) money (~> 6.12) @@ -412,9 +410,6 @@ GEM net-protocol newrelic_rpm (9.16.0) nio4r (2.7.4) - nokogiri (1.18.8) - mini_portile2 (~> 2.8.2) - racc (~> 1.4) nokogiri (1.18.8-arm64-darwin) racc (~> 1.4) nokogiri (1.18.8-x86_64-darwin) @@ -564,12 +559,6 @@ GEM rdoc (6.13.1) psych (>= 4.0.0) recaptcha (5.19.0) - recurring_select (3.0.1) - coffee-rails (>= 3.1) - ice_cube (>= 0.11) - jquery-rails (>= 3.0) - rails (>= 5.2) - sass-rails (>= 4.0) regexp_parser (2.10.0) reline (0.6.0) io-console (~> 0.5) @@ -742,7 +731,6 @@ GEM PLATFORMS arm64-darwin - ruby x86_64-darwin x86_64-linux x86_64-linux-gnu @@ -783,8 +771,8 @@ DEPENDENCIES geocoder guard-rspec icalendar - importmap-rails (~> 2.1) ice_cube + importmap-rails (~> 2.1) jbuilder jwt kaminari @@ -815,7 +803,6 @@ DEPENDENCIES rails-controller-testing rails-erd recaptcha - recurring_select rolify (~> 6.0) rspec-rails (~> 7.1.0) rubocop @@ -839,4 +826,4 @@ DEPENDENCIES webmock (~> 3.24) BUNDLED WITH - 2.6.6 + 2.6.8 From 4e347597382d925f974d1a859b04385eebe90433 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sun, 11 May 2025 07:57:06 -0600 Subject: [PATCH 39/94] Changes made by linter --- spec/requests/reports/distributions_summary_requests_spec.rb | 2 +- spec/requests/reports/donations_summary_spec.rb | 2 +- spec/requests/reports/manufacturer_donations_summary_spec.rb | 2 +- spec/requests/reports/product_drives_summary_spec.rb | 2 +- spec/requests/reports/purchases_summary_requests_spec.rb | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/requests/reports/distributions_summary_requests_spec.rb b/spec/requests/reports/distributions_summary_requests_spec.rb index 63d6e6e25b..260aed326f 100644 --- a/spec/requests/reports/distributions_summary_requests_spec.rb +++ b/spec/requests/reports/distributions_summary_requests_spec.rb @@ -37,7 +37,7 @@ create :distribution, :with_items, item_quantity: 17, issued_at: 30.days.ago, organization: organization end - let(:formatted_date_range) { date_range.map { _1.to_fs(:date_picker) }.join(" - ") } + let(:formatted_date_range) { date_range.map { it.to_fs(:date_picker) }.join(" - ") } before do get reports_distributions_summary_path, params: {filters: {date_range: formatted_date_range}} diff --git a/spec/requests/reports/donations_summary_spec.rb b/spec/requests/reports/donations_summary_spec.rb index 89daa7e96f..e0dff5cf84 100644 --- a/spec/requests/reports/donations_summary_spec.rb +++ b/spec/requests/reports/donations_summary_spec.rb @@ -38,7 +38,7 @@ create :donation, :with_items, item_quantity: 17, issued_at: 30.days.ago, organization: organization end - let(:formatted_date_range) { date_range.map { _1.to_fs(:date_picker) }.join(" - ") } + let(:formatted_date_range) { date_range.map { it.to_fs(:date_picker) }.join(" - ") } before do get reports_donations_summary_path(user.organization), params: {filters: {date_range: formatted_date_range}} diff --git a/spec/requests/reports/manufacturer_donations_summary_spec.rb b/spec/requests/reports/manufacturer_donations_summary_spec.rb index 0b59455c9b..41b7303d6a 100644 --- a/spec/requests/reports/manufacturer_donations_summary_spec.rb +++ b/spec/requests/reports/manufacturer_donations_summary_spec.rb @@ -28,7 +28,7 @@ end context "with manufacturer donations in the last year" do - let(:formatted_date_range) { date_range.map { _1.to_fs(:date_picker) }.join(" - ") } + let(:formatted_date_range) { date_range.map { it.to_fs(:date_picker) }.join(" - ") } let(:date_range) { [1.year.ago, 0.days.ago] } let!(:donations) do [ diff --git a/spec/requests/reports/product_drives_summary_spec.rb b/spec/requests/reports/product_drives_summary_spec.rb index d6007e8c75..72722729c9 100644 --- a/spec/requests/reports/product_drives_summary_spec.rb +++ b/spec/requests/reports/product_drives_summary_spec.rb @@ -35,7 +35,7 @@ create :product_drive_donation, :with_items, item_quantity: 117, money_raised: 1700, issued_at: 30.days.ago, organization: organization end - let(:formatted_date_range) { date_range.map { _1.to_fs(:date_picker) }.join(" - ") } + let(:formatted_date_range) { date_range.map { it.to_fs(:date_picker) }.join(" - ") } before do get reports_product_drives_summary_path(user.organization), params: {filters: {date_range: formatted_date_range}} diff --git a/spec/requests/reports/purchases_summary_requests_spec.rb b/spec/requests/reports/purchases_summary_requests_spec.rb index f845c4e831..0e0ff9d89f 100644 --- a/spec/requests/reports/purchases_summary_requests_spec.rb +++ b/spec/requests/reports/purchases_summary_requests_spec.rb @@ -29,7 +29,7 @@ create :purchase, :with_items, item_quantity: 17, issued_at: 30.days.ago, organization: organization end - let(:formatted_date_range) { date_range.map { _1.to_fs(:date_picker) }.join(" - ") } + let(:formatted_date_range) { date_range.map { it.to_fs(:date_picker) }.join(" - ") } before do get reports_purchases_summary_path, params: {filters: {date_range: formatted_date_range}} From 4d3713ed97588803271e3a764242b0f6bc7429c2 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sun, 11 May 2025 10:24:18 -0600 Subject: [PATCH 40/94] Updated spec to not use Organization.short_name --- spec/system/admin/organizations_system_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/system/admin/organizations_system_spec.rb b/spec/system/admin/organizations_system_spec.rb index 579ea305a3..b07e1c2345 100644 --- a/spec/system/admin/organizations_system_spec.rb +++ b/spec/system/admin/organizations_system_spec.rb @@ -53,7 +53,7 @@ def reload_record def post_form_submit expect(page.find(".alert")).to have_content "Updated organization!" - within("tr.#{first_org.short_name}") do + within(find("tr", text:"#{first_org.name}")) do first(:link, "View").click end end From 96e232ff959febdb6c9dcc201ff8fde4cbe4c254 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sun, 11 May 2025 10:28:56 -0600 Subject: [PATCH 41/94] Forgot to run linter --- spec/system/admin/organizations_system_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/system/admin/organizations_system_spec.rb b/spec/system/admin/organizations_system_spec.rb index b07e1c2345..005f425acb 100644 --- a/spec/system/admin/organizations_system_spec.rb +++ b/spec/system/admin/organizations_system_spec.rb @@ -53,7 +53,7 @@ def reload_record def post_form_submit expect(page.find(".alert")).to have_content "Updated organization!" - within(find("tr", text:"#{first_org.name}")) do + within(find("tr", text: first_org.name.to_s)) do first(:link, "View").click end end From 819897fc1c6732e2238ad45e1fd8fc11519dd9f0 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Tue, 13 May 2025 08:48:56 -0600 Subject: [PATCH 42/94] Fixed migration not correctly converting reminder_day to reminder_schedule --- db/migrate/20240715162837_seed_reminder_schedule_data.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/migrate/20240715162837_seed_reminder_schedule_data.rb b/db/migrate/20240715162837_seed_reminder_schedule_data.rb index 5fdc96bbe7..962de0648f 100644 --- a/db/migrate/20240715162837_seed_reminder_schedule_data.rb +++ b/db/migrate/20240715162837_seed_reminder_schedule_data.rb @@ -3,14 +3,14 @@ def change for o in Organization.all if o.reminder_day.present? reminder_schedule = o.convert_to_reminder_schedule(o.reminder_day) - o.update(reminder_schedule: reminder_schedule) + o.update_column(:reminder_schedule, reminder_schedule) end end for pg in PartnerGroup.all if pg.reminder_day.present? reminder_schedule = pg.convert_to_reminder_schedule(pg.reminder_day) - pg.update(reminder_schedule: reminder_schedule) + pg.update_column(:reminder_schedule, reminder_schedule) end end end From 15b7c5579ed603ee0914983107943c442ff648e3 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 14 May 2025 08:36:39 -0600 Subject: [PATCH 43/94] First pass at adding start_date to deadline day form --- .../admin/organizations_controller.rb | 2 +- app/controllers/organizations_controller.rb | 2 +- app/controllers/partner_groups_controller.rb | 2 +- .../controllers/deadline_day_controller.js | 16 ++++++++++++++-- app/models/concerns/deadlinable.rb | 16 ++++++++++++---- app/views/shared/_deadline_day_fields.html.erb | 8 ++++++++ 6 files changed, 37 insertions(+), 9 deletions(-) diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb index 10d7c80fdb..6f072b94cd 100644 --- a/app/controllers/admin/organizations_controller.rb +++ b/app/controllers/admin/organizations_controller.rb @@ -87,7 +87,7 @@ def destroy def organization_params params.require(:organization) .permit(:name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_email_text, :account_request_id, - :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month, :deadline_day, + :start_date, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month, :deadline_day, users_attributes: %i(name email organization_admin), account_request_attributes: %i(ndbn_member_id id)) end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 0542f58a46..d4b13777d6 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -102,7 +102,7 @@ def organization_params :ytd_on_distribution_printout, :one_step_partner_invite, :hide_value_columns_on_receipt, :hide_package_column_on_receipt, :signature_for_distribution_pdf, :receive_email_on_requests, - :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, + :start_date, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month, partner_form_fields: [], request_unit_names: [] diff --git a/app/controllers/partner_groups_controller.rb b/app/controllers/partner_groups_controller.rb index 9085dbdee3..f0769ad902 100644 --- a/app/controllers/partner_groups_controller.rb +++ b/app/controllers/partner_groups_controller.rb @@ -56,7 +56,7 @@ def set_partner_group def partner_group_params params.require(:partner_group).permit(:name, :send_reminders, :reminder_schedule, - :deadline_day, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month, item_category_ids: []) + :deadline_day, :start_date, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month, item_category_ids: []) end def set_items_categories diff --git a/app/javascript/controllers/deadline_day_controller.js b/app/javascript/controllers/deadline_day_controller.js index 336817f9ef..ed3bf31bf8 100644 --- a/app/javascript/controllers/deadline_day_controller.js +++ b/app/javascript/controllers/deadline_day_controller.js @@ -15,24 +15,36 @@ const WEEKDAY_NUM_TO_OBJ = { export default class extends Controller { static targets = [ - 'everyNthMonth', 'byDayOfMonth', 'byDayOfWeek', 'dayOfMonthFields', 'dayOfMonth', + 'startDate', 'everyNthMonth', 'byDayOfMonth', 'byDayOfWeek', 'dayOfMonthFields', 'dayOfMonth', 'dayOfWeekFields', 'everyNthDay', 'dayOfWeek', 'deadlineDay', 'reminderText', 'deadlineText' ] + static dateParser = /(\d{4})-(\d{2})-(\d{2})/; + sourceChange() { let reminder_date = null; let deadline_date = null; + + let match = this.startDateTarget.value.match(this.constructor.dateParser); + let startDate = new Date( + match[1], + match[2]-1, // Subtracting 1 because the Date constructor uses month indices, but year and day numbers + match[3] + ); + if (this.byDayOfMonthTarget.checked && this.dayOfMonthTarget.value) { const rule = new RRule({ + dtstart: startDate, freq: RRule.MONTHLY, interval: parseInt(this.everyNthMonthTarget.value), bymonthday: parseInt(this.dayOfMonthTarget.value), - count: 1, + count: 1 }) reminder_date = rule.all()[0] } if (this.byDayOfWeekTarget.checked && this.everyNthDayTarget.value && (this.dayOfWeekTarget.value)) { const rule = new RRule({ + dtstart: startDate, freq: RRule.MONTHLY, interval: parseInt(this.everyNthMonthTarget.value), byweekday: WEEKDAY_NUM_TO_OBJ[ parseInt(this.dayOfWeekTarget.value) ].nth( parseInt(this.everyNthDayTarget.value) ), diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index de49115e9e..4f4a880f66 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -16,7 +16,7 @@ module Deadlinable }.freeze included do - attr_accessor :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month + attr_accessor :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month, :start_date validates :deadline_day, numericality: {only_integer: true, less_than_or_equal_to: MAX_DAY_OF_MONTH, greater_than_or_equal_to: MIN_DAY_OF_MONTH, allow_nil: true} validate :day_of_month_on_deadline_day?, if: -> { day_of_month.present? } @@ -45,6 +45,7 @@ def from_ical(ical) day_of_month = rule["validations"][:day_of_month]&.first&.value results = {} + results[:start_date] = schedule.start_time results[:by_month_or_week] = day_of_month ? "day_of_month" : "day_of_week" results[:day_of_month] = day_of_month results[:day_of_week] = rule["validations"][:day_of_week]&.first&.day @@ -56,9 +57,16 @@ def from_ical(ical) end def get_values_from_reminder_schedule - return if reminder_schedule.blank? + if reminder_schedule.blank? + self.start_date = Time.zone.today + return + end results = from_ical(reminder_schedule) - return if results.nil? + if results.nil? + self.start_date = Time.zone.today + return + end + self.start_date = results[:start_date] self.by_month_or_week = results[:by_month_or_week] self.day_of_month = results[:day_of_month] self.day_of_week = results[:day_of_week] @@ -87,7 +95,7 @@ def should_update_reminder_schedule end def create_schedule - schedule = IceCube::Schedule.new(Time.zone.now.to_date) + schedule = IceCube::Schedule.new(Time.zone.parse(start_date)) return nil if by_month_or_week.blank? || every_nth_month.blank? if by_month_or_week == "day_of_month" return nil if day_of_month.blank? diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index d040c05dc2..5f0a1d50df 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -1,5 +1,13 @@
+ <%= f.label :start_date, 'When should the reminders start being sent?' %> + <%= f.input :start_date, + as: :date, + label: false, + html5: true, + wrapper: :input_group, + input_html: {"data-deadline-day-target" => "startDate", "data-action" => "deadline-day#sourceChange"} %> + <%= f.label :every_nth_month, 'How frequently should reminders be sent (e.g. "monthly", "every 3 months", etc.)?' %> <%= f.input :every_nth_month, collection: Deadlinable::EVERY_NTH_MONTH_COLLECTION, From 8852be3c994790302e075dcb331fd935f5698b79 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 14 May 2025 10:40:22 -0600 Subject: [PATCH 44/94] Updated tests to consider start date --- spec/models/concerns/deadlinable_spec.rb | 5 +++-- .../deadline_day_fields_shared_example.rb | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index af8b2d09c0..735764965b 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -136,6 +136,7 @@ def deadline_day? it "from_ical returns hash of fields from schedule in ICAL format" do ical_schedule = "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" expect(dummy.from_ical(ical_schedule)).to eq( + start_date: Time.zone.local( 2020, 10, 10 ), by_month_or_week: "day_of_month", day_of_month: 10, day_of_week: nil, @@ -181,7 +182,7 @@ def deadline_day? context "by day of month" do before do - travel_to Time.zone.local(2020, 10, 10) + dummy.start_date = "2020/10/10" dummy.by_month_or_week = "day_of_month" end @@ -205,7 +206,7 @@ def deadline_day? context "by day of week" do before do - travel_to Time.zone.local(2020, 10, 10) + dummy.start_date = "2020/10/10" dummy.by_month_or_week = "day_of_week" end diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 11075e4365..6e7bddcfcc 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -91,6 +91,14 @@ expect(page).to have_content((prior + 1.month).strftime("%b %d %Y")) end + it "after the entered start date date" do + prior = @now - 1.day + fill_in "#{form_prefix}_start_date", with: (prior - 1.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: prior.day + expect(page).to have_content("Your next reminder will be sent on") + expect(page).to have_content(prior.strftime("%b %d %Y")) + end + it "after the current date" do after = @now + 1.day fill_in "#{form_prefix}_day_of_month", with: after.day @@ -98,6 +106,14 @@ expect(page).to have_content(after.strftime("%b %d %Y")) end + it "prior to the entered start date date" do + after = @now + 1.day + fill_in "#{form_prefix}_start_date", with: (after + 1.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: after.day + expect(page).to have_content("Your next reminder will be sent on") + expect(page).to have_content((after + 1.month).strftime("%b %d %Y")) + end + it "and the reminder and deadline dates are different" do fill_in "#{form_prefix}_day_of_month", with: @now.day + 1 fill_in "Default deadline day (final day of month to submit Requests)", with: @now.day + 2 From 33c3944df2ed5fbef00c4c6d76018b16a052ae14 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 14 May 2025 11:01:50 -0600 Subject: [PATCH 45/94] Forgot to add tests for get_values_from_reminder_schedule --- spec/models/concerns/deadlinable_spec.rb | 36 +++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index 735764965b..93f376bde9 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -145,17 +145,35 @@ def deadline_day? ) end + it "get_values_from_reminder_schedule sets deadlineable's start_date to today if there is no schedule" do + dummy.get_values_from_reminder_schedule() + expect(dummy.start_date).to eq(Time.zone.today) + dummy.reminder_schedule = "notavalidschedule" + dummy.get_values_from_reminder_schedule() + expect(dummy.start_date).to eq(Time.zone.today) + end + it "when reminder_schedule is blank should_update_reminder_schedule returns true if day_of_month is present, false otherwise" do expect(dummy.should_update_reminder_schedule).to be_falsey dummy.by_month_or_week = "day_of_month" expect(dummy.should_update_reminder_schedule).to be_truthy end - context "with an existing schedule" do + context "with an existing day_of_month schedule" do before do dummy.reminder_schedule = "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" end + it "get_values_from_reminder_schedule sets deadlineable's fields with values stored in ICAL schedule" do + dummy.get_values_from_reminder_schedule() + expect(dummy.start_date).to eq(Time.zone.local(2020, 10, 10)) + expect(dummy.by_month_or_week).to eq("day_of_month") + expect(dummy.day_of_month).to eq(10) + expect(dummy.day_of_week).to eq(nil) + expect(dummy.every_nth_day).to eq(nil) + expect(dummy.every_nth_month).to eq(1) + end + it "should_update_reminder_schedule returns false if no fields differ" do dummy.by_month_or_week = "day_of_month" dummy.day_of_month = "10" @@ -180,6 +198,22 @@ def deadline_day? end end + context "with an existing day_of_week schedule" do + before do + dummy.reminder_schedule = "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYDAY=3WE" + end + + it "get_values_from_reminder_schedule sets deadlineable's fields with values stored in ICAL schedule" do + dummy.get_values_from_reminder_schedule() + expect(dummy.start_date).to eq(Time.zone.local(2020, 10, 10)) + expect(dummy.by_month_or_week).to eq("day_of_week") + expect(dummy.day_of_month).to eq(nil) + expect(dummy.day_of_week).to eq(3) + expect(dummy.every_nth_day).to eq(3) + expect(dummy.every_nth_month).to eq(1) + end + end + context "by day of month" do before do dummy.start_date = "2020/10/10" From 9c98385c656bc575c4fe24d0c78dc5ded6c5f79f Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 14 May 2025 11:06:26 -0600 Subject: [PATCH 46/94] Changes made by linter --- spec/models/concerns/deadlinable_spec.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index 93f376bde9..2b4365ed8d 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -136,7 +136,7 @@ def deadline_day? it "from_ical returns hash of fields from schedule in ICAL format" do ical_schedule = "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" expect(dummy.from_ical(ical_schedule)).to eq( - start_date: Time.zone.local( 2020, 10, 10 ), + start_date: Time.zone.local(2020, 10, 10), by_month_or_week: "day_of_month", day_of_month: 10, day_of_week: nil, @@ -146,10 +146,10 @@ def deadline_day? end it "get_values_from_reminder_schedule sets deadlineable's start_date to today if there is no schedule" do - dummy.get_values_from_reminder_schedule() + dummy.get_values_from_reminder_schedule expect(dummy.start_date).to eq(Time.zone.today) dummy.reminder_schedule = "notavalidschedule" - dummy.get_values_from_reminder_schedule() + dummy.get_values_from_reminder_schedule expect(dummy.start_date).to eq(Time.zone.today) end @@ -165,7 +165,7 @@ def deadline_day? end it "get_values_from_reminder_schedule sets deadlineable's fields with values stored in ICAL schedule" do - dummy.get_values_from_reminder_schedule() + dummy.get_values_from_reminder_schedule expect(dummy.start_date).to eq(Time.zone.local(2020, 10, 10)) expect(dummy.by_month_or_week).to eq("day_of_month") expect(dummy.day_of_month).to eq(10) @@ -204,7 +204,7 @@ def deadline_day? end it "get_values_from_reminder_schedule sets deadlineable's fields with values stored in ICAL schedule" do - dummy.get_values_from_reminder_schedule() + dummy.get_values_from_reminder_schedule expect(dummy.start_date).to eq(Time.zone.local(2020, 10, 10)) expect(dummy.by_month_or_week).to eq("day_of_week") expect(dummy.day_of_month).to eq(nil) From 1ec26b33725c60d0572c2c28bcbbe32b73b65032 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 14 May 2025 12:39:47 -0600 Subject: [PATCH 47/94] Reworked create_schedule to start the schedule at the current date time if a start_date isn't provided --- app/models/concerns/deadlinable.rb | 2 +- spec/models/concerns/deadlinable_spec.rb | 78 ++++++++++++++++++------ 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index 4f4a880f66..5e1f6b054c 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -95,7 +95,7 @@ def should_update_reminder_schedule end def create_schedule - schedule = IceCube::Schedule.new(Time.zone.parse(start_date)) + schedule = IceCube::Schedule.new(start_date ? Time.zone.parse(start_date) : Time.zone.now.to_date) return nil if by_month_or_week.blank? || every_nth_month.blank? if by_month_or_week == "day_of_month" return nil if day_of_month.blank? diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index 2b4365ed8d..9b6b7fba40 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -216,17 +216,37 @@ def deadline_day? context "by day of month" do before do - dummy.start_date = "2020/10/10" dummy.by_month_or_week = "day_of_month" end - it "create_schedule returns schedule in ICAL format" do - dummy.day_of_month = "10" - dummy.every_nth_month = "1" - expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" - dummy.day_of_month = "15" - dummy.every_nth_month = "3" - expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15" + context "with a specified start date" do + before do + dummy.start_date = "2020/10/10" + end + + it "create_schedule returns schedule in ICAL format" do + dummy.day_of_month = "10" + dummy.every_nth_month = "1" + expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" + dummy.day_of_month = "15" + dummy.every_nth_month = "3" + expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15" + end + end + + context "without a specified start date" do + before do + travel_to Time.zone.local(2020, 11, 11) + end + + it "create_schedule returns schedule in ICAL format" do + dummy.day_of_month = "10" + dummy.every_nth_month = "1" + expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201111T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" + dummy.day_of_month = "15" + dummy.every_nth_month = "3" + expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201111T000000\nRRULE:FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15" + end end it "create_schedule returns nil if needed fields are missing" do @@ -240,19 +260,41 @@ def deadline_day? context "by day of week" do before do - dummy.start_date = "2020/10/10" dummy.by_month_or_week = "day_of_week" end - it "create_schedule returns schedule in ICAL format" do - dummy.day_of_week = "0" - dummy.every_nth_day = "1" - dummy.every_nth_month = "1" - expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYDAY=1SU" - dummy.day_of_week = "3" - dummy.every_nth_day = "3" - dummy.every_nth_month = "1" - expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYDAY=3WE" + context "with a specified start date" do + before do + dummy.start_date = "2020/10/10" + end + + it "create_schedule returns schedule in ICAL format" do + dummy.day_of_week = "0" + dummy.every_nth_day = "1" + dummy.every_nth_month = "1" + expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYDAY=1SU" + dummy.day_of_week = "3" + dummy.every_nth_day = "3" + dummy.every_nth_month = "1" + expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYDAY=3WE" + end + end + + context "without a specified start date" do + before do + travel_to Time.zone.local(2020, 11, 11) + end + + it "create_schedule returns schedule in ICAL format" do + dummy.day_of_week = "0" + dummy.every_nth_day = "1" + dummy.every_nth_month = "1" + expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201111T000000\nRRULE:FREQ=MONTHLY;BYDAY=1SU" + dummy.day_of_week = "3" + dummy.every_nth_day = "3" + dummy.every_nth_month = "1" + expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201111T000000\nRRULE:FREQ=MONTHLY;BYDAY=3WE" + end end it "create_schedule returns nil if needed fields are missing" do From a7f6be73f6cf17b71ae73403a7e86804e143732a Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Tue, 20 May 2025 06:57:22 -0600 Subject: [PATCH 48/94] Reworked wording of deadline day form, updated javascript controller to calculate next reminder day after the current date --- .../controllers/deadline_day_controller.js | 36 ++++++++++++++----- .../shared/_deadline_day_fields.html.erb | 16 ++++----- .../deadline_day_fields_shared_example.rb | 22 ++++++------ 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/app/javascript/controllers/deadline_day_controller.js b/app/javascript/controllers/deadline_day_controller.js index ed3bf31bf8..9c43274d7d 100644 --- a/app/javascript/controllers/deadline_day_controller.js +++ b/app/javascript/controllers/deadline_day_controller.js @@ -21,6 +21,20 @@ export default class extends Controller { static dateParser = /(\d{4})-(\d{2})-(\d{2})/; + getFirstOccurrenceAfterToday( occurrences, today ) { + let index = occurrences.length - 1 + let firstOccurrence = null + while (index >= 0){ + if (occurrences[index].getTime() > today.getTime()) { + firstOccurrence = occurrences[index] + index-- + } else { + break + } + } + return firstOccurrence + } + sourceChange() { let reminder_date = null; let deadline_date = null; @@ -31,27 +45,33 @@ export default class extends Controller { match[2]-1, // Subtracting 1 because the Date constructor uses month indices, but year and day numbers match[3] ); + let monthlyInterval = parseInt(this.everyNthMonthTarget.value); + // Calculate the next reminder date after the start date, or the current date, whichever is greater. + // Do it this way to avoid the next reminder/deadline date being a date in the past. + let today = new Date() + let untilDate = new Date( Math.max(...[ startDate, today ]) ) + untilDate.setMonth( untilDate.getMonth() + monthlyInterval ) if (this.byDayOfMonthTarget.checked && this.dayOfMonthTarget.value) { const rule = new RRule({ dtstart: startDate, freq: RRule.MONTHLY, - interval: parseInt(this.everyNthMonthTarget.value), + interval: monthlyInterval, bymonthday: parseInt(this.dayOfMonthTarget.value), - count: 1 + until: untilDate }) - reminder_date = rule.all()[0] + reminder_date = this.getFirstOccurrenceAfterToday( rule.all(), today ) } if (this.byDayOfWeekTarget.checked && this.everyNthDayTarget.value && (this.dayOfWeekTarget.value)) { const rule = new RRule({ dtstart: startDate, freq: RRule.MONTHLY, - interval: parseInt(this.everyNthMonthTarget.value), + interval: monthlyInterval, byweekday: WEEKDAY_NUM_TO_OBJ[ parseInt(this.dayOfWeekTarget.value) ].nth( parseInt(this.everyNthDayTarget.value) ), wkst: RRule.SU, - count: 1 + until: untilDate }) - reminder_date = rule.all()[0] + reminder_date = this.getFirstOccurrenceAfterToday( rule.all(), today ) } if (reminder_date && this.deadlineDayTarget.value) { deadline_date = new Date(reminder_date.getTime()); @@ -74,14 +94,14 @@ export default class extends Controller { $(this.reminderTextTarget).text("Reminder day must be between 1 and 28"); } else { $(this.reminderTextTarget).removeClass('text-danger').addClass('text-muted'); - $(this.reminderTextTarget).text(reminder_date ? `Your next reminder will be sent on ${reminder_date.toDateString()}.` : ""); + $(this.reminderTextTarget).text(reminder_date ? `Your next reminder date is ${reminder_date.toDateString()}.` : ""); } if (deadlineDay < 1 || deadlineDay > 28){ $(this.deadlineTextTarget).removeClass('text-muted').addClass('text-danger'); $(this.deadlineTextTarget).text("Deadline day must be between 1 and 28"); } else { $(this.deadlineTextTarget).removeClass('text-danger').addClass('text-muted'); - $(this.deadlineTextTarget).text(deadline_date ? `Your next deadline will be on ${deadline_date.toDateString()}.` : ""); + $(this.deadlineTextTarget).text(deadline_date ? `Your next deadline date is ${deadline_date.toDateString()}.` : ""); } } } diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index 5f0a1d50df..4b153c622a 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -1,13 +1,5 @@
- <%= f.label :start_date, 'When should the reminders start being sent?' %> - <%= f.input :start_date, - as: :date, - label: false, - html5: true, - wrapper: :input_group, - input_html: {"data-deadline-day-target" => "startDate", "data-action" => "deadline-day#sourceChange"} %> - <%= f.label :every_nth_month, 'How frequently should reminders be sent (e.g. "monthly", "every 3 months", etc.)?' %> <%= f.input :every_nth_month, collection: Deadlinable::EVERY_NTH_MONTH_COLLECTION, @@ -17,6 +9,14 @@ default: 1, input_html: {style: 'width: 200px', "data-deadline-day-target" => "everyNthMonth", "data-action" => "deadline-day#sourceChange"} %> + <%= f.label :start_date, 'Reminder start being sent after:' %> + <%= f.input :start_date, + as: :date, + label: false, + html5: true, + wrapper: :input_group, + input_html: {"data-deadline-day-target" => "startDate", "data-action" => "deadline-day#sourceChange"} %> + <%= f.label :by_month_or_week, 'Send reminders on a specific day of the month (e.g. "the 5th") or a day of the week (eg "the first Tuesday")?' %>
<%= f.radio_button :by_month_or_week, diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 6e7bddcfcc..028b1d4cc3 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -53,8 +53,8 @@ fill_in "#{form_prefix}_day_of_month", with: 15 fill_in "Default deadline day (final day of month to submit Requests)", with: 15 expect(page).to have_content("Reminder day cannot be the same as deadline day.") - expect(page).to_not have_content("Your next reminder will be sent on") - expect(page).to_not have_content("Your next deadline will be on") + expect(page).to_not have_content("Your next reminder date is") + expect(page).to_not have_content("Your next deadline date is") end it "warns the user if the reminder day is outside the range of 1 to 28" do @@ -87,7 +87,7 @@ it "prior to the current date" do prior = @now - 1.day fill_in "#{form_prefix}_day_of_month", with: prior.day - expect(page).to have_content("Your next reminder will be sent on") + expect(page).to have_content("Your next reminder date is") expect(page).to have_content((prior + 1.month).strftime("%b %d %Y")) end @@ -95,14 +95,14 @@ prior = @now - 1.day fill_in "#{form_prefix}_start_date", with: (prior - 1.day).strftime("%Y-%m-%d") fill_in "#{form_prefix}_day_of_month", with: prior.day - expect(page).to have_content("Your next reminder will be sent on") + expect(page).to have_content("Your next reminder date is") expect(page).to have_content(prior.strftime("%b %d %Y")) end it "after the current date" do after = @now + 1.day fill_in "#{form_prefix}_day_of_month", with: after.day - expect(page).to have_content("Your next reminder will be sent on") + expect(page).to have_content("Your next reminder date is") expect(page).to have_content(after.strftime("%b %d %Y")) end @@ -110,14 +110,14 @@ after = @now + 1.day fill_in "#{form_prefix}_start_date", with: (after + 1.day).strftime("%Y-%m-%d") fill_in "#{form_prefix}_day_of_month", with: after.day - expect(page).to have_content("Your next reminder will be sent on") + expect(page).to have_content("Your next reminder date is") expect(page).to have_content((after + 1.month).strftime("%b %d %Y")) end it "and the reminder and deadline dates are different" do fill_in "#{form_prefix}_day_of_month", with: @now.day + 1 fill_in "Default deadline day (final day of month to submit Requests)", with: @now.day + 2 - expect(page).to have_content("Your next deadline will be on") + expect(page).to have_content("Your next deadline date is") expect(page).to have_content((@now + 2.days).strftime("%b %d %Y")) end end @@ -138,7 +138,7 @@ select(Deadlinable::DAY_OF_WEEK_COLLECTION[prior.wday][0], from: "#{form_prefix}_day_of_week") schedule = IceCube::Schedule.new schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(prior.wday => [every_nth_day])) - expect(page).to have_content("Your next reminder will be sent on") + expect(page).to have_content("Your next reminder date is") expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end @@ -152,7 +152,7 @@ select(Deadlinable::DAY_OF_WEEK_COLLECTION[after.wday][0], from: "#{form_prefix}_day_of_week") schedule = IceCube::Schedule.new schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(after.wday => [every_nth_day])) - expect(page).to have_content("Your next reminder will be sent on") + expect(page).to have_content("Your next reminder date is") expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end end @@ -165,12 +165,12 @@ fill_in "Default deadline day (final day of month to submit Requests)", with: 21 reminder_text = find('small[data-deadline-day-target="reminderText"]').text - reminder_text.slice!("Your next reminder will be sent on ") + reminder_text.slice!("Your next reminder date is ") reminder_text.slice!(".") shown_recurrence_date = Time.zone.strptime(reminder_text, "%a %b %d %Y") deadline_text = find('small[data-deadline-day-target="deadlineText"]').text - deadline_text.slice!("Your next deadline will be on ") + deadline_text.slice!("Your next deadline date is ") deadline_text.slice!(".") shown_deadline_date = Time.zone.strptime(deadline_text, "%a %b %d %Y") From 77e861b899aba066abe2990a5bec111114e38381 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Tue, 20 May 2025 06:58:54 -0600 Subject: [PATCH 49/94] Removed confusing commented out configurations --- config/environments/production.rb | 3 --- config/environments/staging.rb | 3 --- 2 files changed, 6 deletions(-) diff --git a/config/environments/production.rb b/config/environments/production.rb index 41e2839562..076e8af34b 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -63,9 +63,6 @@ # Use a different cache store in production. config.cache_store = :solid_cache_store - # Use a real queuing backend for Active Job (and separate queues per environment) - # config.active_job.queue_adapter = :resque - # config.active_job.queue_name_prefix = "diaper_#{Rails.env}" config.action_mailer.perform_caching = false # Ignore bad email addresses and do not raise email delivery errors. diff --git a/config/environments/staging.rb b/config/environments/staging.rb index 0e6a4d6ef7..ae95bcf668 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -61,9 +61,6 @@ # Use a different cache store in production. # config.cache_store = :mem_cache_store - # Use a real queuing backend for Active Job (and separate queues per environment) - # config.active_job.queue_adapter = :resque - # config.active_job.queue_name_prefix = "diaper_#{Rails.env}" config.action_mailer.perform_caching = false # Ignore bad email addresses and do not raise email delivery errors. From b77f516e4f328ae57126612b2650a1c2e7e6e5da Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Tue, 20 May 2025 10:28:37 -0600 Subject: [PATCH 50/94] Expanded tests to cover all combinations of the order of the start, current, and reminder dates --- .../deadline_day_fields_shared_example.rb | 257 +++++++++++++++--- 1 file changed, 219 insertions(+), 38 deletions(-) diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 028b1d4cc3..40ff00bdd1 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -84,42 +84,106 @@ @now = Time.zone.now end - it "prior to the current date" do - prior = @now - 1.day - fill_in "#{form_prefix}_day_of_month", with: prior.day + it "prior to the current date and start date" do + fill_in "#{form_prefix}_day_of_month", with: (@now - 2.day).day + fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") - expect(page).to have_content((prior + 1.month).strftime("%b %d %Y")) + schedule = IceCube::Schedule.new(@now - 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 2.day).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + + fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") + schedule = IceCube::Schedule.new(@now + 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 2.day).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + + fill_in "#{form_prefix}_start_date", with: (@now).strftime("%Y-%m-%d") + schedule = IceCube::Schedule.new(@now) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 2.day).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end + + it "after the current date and start date" do + fill_in "#{form_prefix}_day_of_month", with: (@now + 2.day).day + fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(@now - 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 2.day).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + + fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") + schedule = IceCube::Schedule.new(@now + 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 2.day).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + + fill_in "#{form_prefix}_start_date", with: (@now).strftime("%Y-%m-%d") + schedule = IceCube::Schedule.new(@now) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 2.day).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end + + it "after the start date and prior to the current date" do + fill_in "#{form_prefix}_start_date", with: (@now - 2.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: (@now - 1.day).day + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(@now - 2.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 1.day).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end + + it "after the current date and prior to the start date" do + fill_in "#{form_prefix}_start_date", with: (@now + 2.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: (@now + 1.day).day + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(@now + 2.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 1.day).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end - it "after the entered start date date" do - prior = @now - 1.day - fill_in "#{form_prefix}_start_date", with: (prior - 1.day).strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: prior.day + it "same as the current date and prior to the start date" do + fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: (@now).day expect(page).to have_content("Your next reminder date is") - expect(page).to have_content(prior.strftime("%b %d %Y")) + schedule = IceCube::Schedule.new(@now + 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end - it "after the current date" do - after = @now + 1.day - fill_in "#{form_prefix}_day_of_month", with: after.day + it "same as the current date and after the start date" do + fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: (@now).day expect(page).to have_content("Your next reminder date is") - expect(page).to have_content(after.strftime("%b %d %Y")) + schedule = IceCube::Schedule.new(@now - 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end - it "prior to the entered start date date" do - after = @now + 1.day - fill_in "#{form_prefix}_start_date", with: (after + 1.day).strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: after.day + it "same as the start date and prior to the current date" do + fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: (@now - 1.day).day expect(page).to have_content("Your next reminder date is") - expect(page).to have_content((after + 1.month).strftime("%b %d %Y")) + schedule = IceCube::Schedule.new(@now - 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 1.day).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end - it "and the reminder and deadline dates are different" do - fill_in "#{form_prefix}_day_of_month", with: @now.day + 1 - fill_in "Default deadline day (final day of month to submit Requests)", with: @now.day + 2 - expect(page).to have_content("Your next deadline date is") - expect(page).to have_content((@now + 2.days).strftime("%b %d %Y")) + it "same as the start date and after the current date" do + fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: (@now + 1.day).day + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(@now + 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 1.day).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end + + it "same as the start and current date" do + fill_in "#{form_prefix}_start_date", with: (@now).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: (@now).day + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(@now) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now).day)) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end end context "when the reminder is a day of the week" do @@ -128,31 +192,148 @@ @now = Time.zone.now end - it "prior to the current day" do - prior = @now - 1.day - every_nth_day = ((prior.day - 1) / 7) + 1 + def calc_every_nth_day( target_date ) + every_nth_day = ((target_date.day - 1) / 7) + 1 if every_nth_day > 4 every_nth_day = -1 end + every_nth_day + end + + it "prior to the current date and start date" do + target_date = @now - 2.day + every_nth_day = calc_every_nth_day( target_date ) select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") - select(Deadlinable::DAY_OF_WEEK_COLLECTION[prior.wday][0], from: "#{form_prefix}_day_of_week") - schedule = IceCube::Schedule.new - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(prior.wday => [every_nth_day])) + select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + + fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(@now - 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + + fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") + schedule = IceCube::Schedule.new(@now + 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + + fill_in "#{form_prefix}_start_date", with: (@now).strftime("%Y-%m-%d") + schedule = IceCube::Schedule.new(@now) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end - it "after the current date" do - after = @now + 1.day - every_nth_day = ((after.day - 1) / 7) + 1 - if every_nth_day > 4 - every_nth_day = -1 - end + it "after the current date and start date" do + target_date = @now + 2.day + every_nth_day = calc_every_nth_day( target_date ) select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") - select(Deadlinable::DAY_OF_WEEK_COLLECTION[after.wday][0], from: "#{form_prefix}_day_of_week") - schedule = IceCube::Schedule.new - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(after.wday => [every_nth_day])) + select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + + fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(@now - 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + + fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") + schedule = IceCube::Schedule.new(@now + 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + + fill_in "#{form_prefix}_start_date", with: (@now).strftime("%Y-%m-%d") + schedule = IceCube::Schedule.new(@now) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end + + it "after the start date and prior to the current date" do + target_date = @now - 1.day + every_nth_day = calc_every_nth_day( target_date ) + select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") + select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + + fill_in "#{form_prefix}_start_date", with: (@now - 2.day).strftime("%Y-%m-%d") + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(@now - 2.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end + + it "after the current date and prior to the start date" do + target_date = @now + 1.day + every_nth_day = calc_every_nth_day( target_date ) + select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") + select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + + fill_in "#{form_prefix}_start_date", with: (@now + 2.day).strftime("%Y-%m-%d") + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(@now + 2.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end + + it "same as the current date and prior to the start date" do + target_date = @now + every_nth_day = calc_every_nth_day( target_date ) + select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") + select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + + fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(@now + 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end + + it "same as the current date and after the start date" do + target_date = @now + every_nth_day = calc_every_nth_day( target_date ) + select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") + select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + + fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(@now - 1.day) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end + + it "same as the start date and prior to the current date" do + target_date = @now - 1.day + every_nth_day = calc_every_nth_day( target_date ) + select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") + select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + + fill_in "#{form_prefix}_start_date", with: (target_date).strftime("%Y-%m-%d") + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(target_date) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end + + it "same as the start date and after the current date" do + target_date = @now + 1.day + every_nth_day = calc_every_nth_day( target_date ) + select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") + select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + + fill_in "#{form_prefix}_start_date", with: (target_date).strftime("%Y-%m-%d") + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(target_date) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end + + it "same as the start and current date" do + target_date = @now + every_nth_day = calc_every_nth_day( target_date ) + select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") + select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + + fill_in "#{form_prefix}_start_date", with: (target_date).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(target_date) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end end From 88d293beb72e9d1cc78f635d5f072fd7c8228164 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 21 May 2025 07:45:41 -0600 Subject: [PATCH 51/94] Fixed tests not setting deadline_day which made partner_groups fail validation --- spec/support/deadline_day_fields_shared_example.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 40ff00bdd1..7fa50578d2 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -2,6 +2,7 @@ it "can set a reminder on a day of the month" do choose "Day of Month" fill_in "#{form_prefix}_day_of_month", with: 1 + fill_in "Default deadline day (final day of month to submit Requests)", with: 10 click_on save_button if post_form_submit @@ -15,6 +16,7 @@ choose "Day of the Week" select("First", from: "#{form_prefix}_every_nth_day") select("Sunday", from: "#{form_prefix}_day_of_week") + fill_in "Default deadline day (final day of month to submit Requests)", with: 10 click_on save_button if post_form_submit @@ -28,6 +30,7 @@ select("Every 3 month", from: "How frequently should reminders be sent (e.g. \"monthly\", \"every 3 months\", etc.)?") choose "Day of Month" fill_in "#{form_prefix}_day_of_month", with: 1 + fill_in "Default deadline day (final day of month to submit Requests)", with: 10 click_on save_button if post_form_submit From 72743637566273f2e4a267aff0c7c74aacd5afa9 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Wed, 21 May 2025 17:11:33 -0600 Subject: [PATCH 52/94] Removed unecessary should_update_reminder_schedule and from_ical functions --- app/models/concerns/deadlinable.rb | 56 +++++------------------- app/models/organization.rb | 6 +-- app/models/partner_group.rb | 6 +-- spec/models/concerns/deadlinable_spec.rb | 41 ----------------- 4 files changed, 13 insertions(+), 96 deletions(-) diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb index 5e1f6b054c..98f59f7e11 100644 --- a/app/models/concerns/deadlinable.rb +++ b/app/models/concerns/deadlinable.rb @@ -38,60 +38,26 @@ def show_description(ical) schedule.recurrence_rules.first.to_s end - def from_ical(ical) - return if ical.blank? - schedule = IceCube::Schedule.from_ical(ical) - rule = schedule.recurrence_rules.first.instance_values - day_of_month = rule["validations"][:day_of_month]&.first&.value - - results = {} - results[:start_date] = schedule.start_time - results[:by_month_or_week] = day_of_month ? "day_of_month" : "day_of_week" - results[:day_of_month] = day_of_month - results[:day_of_week] = rule["validations"][:day_of_week]&.first&.day - results[:every_nth_day] = rule["validations"][:day_of_week]&.first&.occ - results[:every_nth_month] = rule["validations"][:interval]&.first&.interval - results - rescue - nil - end - def get_values_from_reminder_schedule if reminder_schedule.blank? self.start_date = Time.zone.today return end - results = from_ical(reminder_schedule) - if results.nil? + + schedule = IceCube::Schedule.from_ical(reminder_schedule) + rule = schedule.recurrence_rules.first.instance_values + if rule.blank? self.start_date = Time.zone.today return end - self.start_date = results[:start_date] - self.by_month_or_week = results[:by_month_or_week] - self.day_of_month = results[:day_of_month] - self.day_of_week = results[:day_of_week] - self.every_nth_day = results[:every_nth_day] - self.every_nth_month = results[:every_nth_month] - end + day_of_month = rule["validations"][:day_of_month]&.first&.value - def should_update_reminder_schedule - if reminder_schedule.blank? - return by_month_or_week.present? - end - sched = from_ical(reminder_schedule) - if by_month_or_week != sched[:by_month_or_week].presence.to_s - return true - end - if by_month_or_week == "day_of_month" - return day_of_month != sched[:day_of_month].presence.to_s || - every_nth_month != sched[:every_nth_month].presence.to_s - end - if by_month_or_week == "day_of_week" - return day_of_week != sched[:day_of_week].presence.to_s || - every_nth_day != sched[:every_nth_day].presence.to_s || - every_nth_month != sched[:every_nth_month].presence.to_s - end - false + self.start_date = schedule.start_time + self.by_month_or_week = day_of_month ? "day_of_month" : "day_of_week" + self.day_of_month = day_of_month + self.day_of_week = rule["validations"][:day_of_week]&.first&.day + self.every_nth_day = rule["validations"][:day_of_week]&.first&.occ + self.every_nth_month = rule["validations"][:interval]&.first&.interval end def create_schedule diff --git a/app/models/organization.rb b/app/models/organization.rb index 5f1674c03b..22f2ebeed1 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -96,11 +96,7 @@ def upcoming end before_save do - # To avoid constantly changing the start date of the reminder_schedule, only update the schedule if something has actually - # changed. - if should_update_reminder_schedule - self.reminder_schedule = create_schedule - end + self.reminder_schedule = create_schedule end after_create do diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index 2edca92f1a..a4998e0076 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -20,11 +20,7 @@ class PartnerGroup < ApplicationRecord has_and_belongs_to_many :item_categories before_save do - # To avoid constantly changing the start date of the reminder_schedule, only update the schedule if something has actually - # changed. - if should_update_reminder_schedule - self.reminder_schedule = create_schedule - end + self.reminder_schedule = create_schedule end validates :name, presence: true, uniqueness: { scope: :organization } diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb index 9b6b7fba40..96e7304ae6 100644 --- a/spec/models/concerns/deadlinable_spec.rb +++ b/spec/models/concerns/deadlinable_spec.rb @@ -133,18 +133,6 @@ def deadline_day? expect(dummy.show_description(ical_schedule)).to eq "Monthly on the 10th day of the month" end - it "from_ical returns hash of fields from schedule in ICAL format" do - ical_schedule = "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" - expect(dummy.from_ical(ical_schedule)).to eq( - start_date: Time.zone.local(2020, 10, 10), - by_month_or_week: "day_of_month", - day_of_month: 10, - day_of_week: nil, - every_nth_day: nil, - every_nth_month: 1 - ) - end - it "get_values_from_reminder_schedule sets deadlineable's start_date to today if there is no schedule" do dummy.get_values_from_reminder_schedule expect(dummy.start_date).to eq(Time.zone.today) @@ -153,12 +141,6 @@ def deadline_day? expect(dummy.start_date).to eq(Time.zone.today) end - it "when reminder_schedule is blank should_update_reminder_schedule returns true if day_of_month is present, false otherwise" do - expect(dummy.should_update_reminder_schedule).to be_falsey - dummy.by_month_or_week = "day_of_month" - expect(dummy.should_update_reminder_schedule).to be_truthy - end - context "with an existing day_of_month schedule" do before do dummy.reminder_schedule = "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" @@ -173,29 +155,6 @@ def deadline_day? expect(dummy.every_nth_day).to eq(nil) expect(dummy.every_nth_month).to eq(1) end - - it "should_update_reminder_schedule returns false if no fields differ" do - dummy.by_month_or_week = "day_of_month" - dummy.day_of_month = "10" - dummy.every_nth_month = "1" - expect(dummy.should_update_reminder_schedule).to be_falsey - end - - it "should_update_reminder_schedule returns true if fields differ" do - dummy.by_month_or_week = "day_of_month" - dummy.day_of_month = "15" - dummy.every_nth_month = "3" - expect(dummy.should_update_reminder_schedule).to be_truthy - end - - it "should_update_reminder_schedule return true if the by_month_or_week field differs" do - dummy.by_month_or_week = "day_of_week" - dummy.day_of_month = "10" - dummy.day_of_week = "0" - dummy.every_nth_day = "1" - dummy.every_nth_month = "1" - expect(dummy.should_update_reminder_schedule).to be_truthy - end end context "with an existing day_of_week schedule" do From 41ad89b27d97e7176e880f6caccbc1b7a204393e Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 22 May 2025 07:14:25 -0600 Subject: [PATCH 53/94] Extended untilDate calculation to account for week day rules that would be more than exactly monthylInterval months out --- app/javascript/controllers/deadline_day_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/controllers/deadline_day_controller.js b/app/javascript/controllers/deadline_day_controller.js index 9c43274d7d..9cc4f78a42 100644 --- a/app/javascript/controllers/deadline_day_controller.js +++ b/app/javascript/controllers/deadline_day_controller.js @@ -50,7 +50,7 @@ export default class extends Controller { // Do it this way to avoid the next reminder/deadline date being a date in the past. let today = new Date() let untilDate = new Date( Math.max(...[ startDate, today ]) ) - untilDate.setMonth( untilDate.getMonth() + monthlyInterval ) + untilDate.setMonth( untilDate.getMonth() + monthlyInterval + 1 ) if (this.byDayOfMonthTarget.checked && this.dayOfMonthTarget.value) { const rule = new RRule({ From dafe290ec8e149bae5c47efae4564c16a6aadf23 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 22 May 2025 07:18:25 -0600 Subject: [PATCH 54/94] Updated factories to not set a deadline_day as it was causing issues with deadline_day_fields_shared_example tests --- spec/factories/organizations.rb | 1 - spec/factories/partner_groups.rb | 1 - 2 files changed, 2 deletions(-) diff --git a/spec/factories/organizations.rb b/spec/factories/organizations.rb index edf1a68d5b..cd3ebcb797 100644 --- a/spec/factories/organizations.rb +++ b/spec/factories/organizations.rb @@ -54,7 +54,6 @@ state { 'VA' } zipcode { '22630' } reminder_schedule { recurrence_schedule_ical } - deadline_day { 20 } logo { Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/logo.jpg"), "image/jpeg") } diff --git a/spec/factories/partner_groups.rb b/spec/factories/partner_groups.rb index 99a74042fa..9570bb0b37 100644 --- a/spec/factories/partner_groups.rb +++ b/spec/factories/partner_groups.rb @@ -21,7 +21,6 @@ sequence(:name) { |n| "Group #{n}" } organization { Organization.try(:first) || create(:organization) } reminder_schedule { recurrence_schedule_ical } - deadline_day { 28 } trait :without_deadlines do reminder_schedule { nil } From e4d64ce1b6f3f80c19fef5bb9da7ff159b46b5f4 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 22 May 2025 07:23:21 -0600 Subject: [PATCH 55/94] Changes made by linter --- .../deadline_day_fields_shared_example.rb | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 7fa50578d2..56dedc2bc1 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -88,76 +88,76 @@ end it "prior to the current date and start date" do - fill_in "#{form_prefix}_day_of_month", with: (@now - 2.day).day + fill_in "#{form_prefix}_day_of_month", with: (@now - 2.days).day fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now - 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 2.day).day)) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 2.days).day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now + 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 2.day).day)) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 2.days).day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - fill_in "#{form_prefix}_start_date", with: (@now).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 2.day).day)) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 2.days).day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "after the current date and start date" do - fill_in "#{form_prefix}_day_of_month", with: (@now + 2.day).day + fill_in "#{form_prefix}_day_of_month", with: (@now + 2.days).day fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now - 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 2.day).day)) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 2.days).day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now + 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 2.day).day)) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 2.days).day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - fill_in "#{form_prefix}_start_date", with: (@now).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 2.day).day)) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 2.days).day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "after the start date and prior to the current date" do - fill_in "#{form_prefix}_start_date", with: (@now - 2.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_start_date", with: (@now - 2.days).strftime("%Y-%m-%d") fill_in "#{form_prefix}_day_of_month", with: (@now - 1.day).day expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now - 2.day) + schedule = IceCube::Schedule.new(@now - 2.days) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 1.day).day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "after the current date and prior to the start date" do - fill_in "#{form_prefix}_start_date", with: (@now + 2.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_start_date", with: (@now + 2.days).strftime("%Y-%m-%d") fill_in "#{form_prefix}_day_of_month", with: (@now + 1.day).day expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now + 2.day) + schedule = IceCube::Schedule.new(@now + 2.days) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 1.day).day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "same as the current date and prior to the start date" do fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: (@now).day + fill_in "#{form_prefix}_day_of_month", with: @now.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now + 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now).day)) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(@now.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "same as the current date and after the start date" do fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: (@now).day + fill_in "#{form_prefix}_day_of_month", with: @now.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now - 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now).day)) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(@now.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end @@ -180,13 +180,13 @@ end it "same as the start and current date" do - fill_in "#{form_prefix}_start_date", with: (@now).strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: (@now).day + fill_in "#{form_prefix}_start_date", with: @now.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: @now.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now).day)) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(@now.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end + end end context "when the reminder is a day of the week" do @@ -195,7 +195,7 @@ @now = Time.zone.now end - def calc_every_nth_day( target_date ) + def calc_every_nth_day(target_date) every_nth_day = ((target_date.day - 1) / 7) + 1 if every_nth_day > 4 every_nth_day = -1 @@ -204,8 +204,8 @@ def calc_every_nth_day( target_date ) end it "prior to the current date and start date" do - target_date = @now - 2.day - every_nth_day = calc_every_nth_day( target_date ) + target_date = @now - 2.days + every_nth_day = calc_every_nth_day(target_date) select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") @@ -220,18 +220,18 @@ def calc_every_nth_day( target_date ) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - fill_in "#{form_prefix}_start_date", with: (@now).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "after the current date and start date" do - target_date = @now + 2.day - every_nth_day = calc_every_nth_day( target_date ) + target_date = @now + 2.days + every_nth_day = calc_every_nth_day(target_date) select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") - + fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now - 1.day) @@ -243,7 +243,7 @@ def calc_every_nth_day( target_date ) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - fill_in "#{form_prefix}_start_date", with: (@now).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) @@ -251,33 +251,33 @@ def calc_every_nth_day( target_date ) it "after the start date and prior to the current date" do target_date = @now - 1.day - every_nth_day = calc_every_nth_day( target_date ) + every_nth_day = calc_every_nth_day(target_date) select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") - - fill_in "#{form_prefix}_start_date", with: (@now - 2.day).strftime("%Y-%m-%d") + + fill_in "#{form_prefix}_start_date", with: (@now - 2.days).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now - 2.day) + schedule = IceCube::Schedule.new(@now - 2.days) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "after the current date and prior to the start date" do target_date = @now + 1.day - every_nth_day = calc_every_nth_day( target_date ) + every_nth_day = calc_every_nth_day(target_date) select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") - - fill_in "#{form_prefix}_start_date", with: (@now + 2.day).strftime("%Y-%m-%d") + + fill_in "#{form_prefix}_start_date", with: (@now + 2.days).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now + 2.day) + schedule = IceCube::Schedule.new(@now + 2.days) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "same as the current date and prior to the start date" do target_date = @now - every_nth_day = calc_every_nth_day( target_date ) + every_nth_day = calc_every_nth_day(target_date) select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") @@ -290,7 +290,7 @@ def calc_every_nth_day( target_date ) it "same as the current date and after the start date" do target_date = @now - every_nth_day = calc_every_nth_day( target_date ) + every_nth_day = calc_every_nth_day(target_date) select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") @@ -303,11 +303,11 @@ def calc_every_nth_day( target_date ) it "same as the start date and prior to the current date" do target_date = @now - 1.day - every_nth_day = calc_every_nth_day( target_date ) + every_nth_day = calc_every_nth_day(target_date) select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") - fill_in "#{form_prefix}_start_date", with: (target_date).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_start_date", with: target_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(target_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) @@ -316,11 +316,11 @@ def calc_every_nth_day( target_date ) it "same as the start date and after the current date" do target_date = @now + 1.day - every_nth_day = calc_every_nth_day( target_date ) + every_nth_day = calc_every_nth_day(target_date) select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") - fill_in "#{form_prefix}_start_date", with: (target_date).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_start_date", with: target_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(target_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) @@ -329,11 +329,11 @@ def calc_every_nth_day( target_date ) it "same as the start and current date" do target_date = @now - every_nth_day = calc_every_nth_day( target_date ) + every_nth_day = calc_every_nth_day(target_date) select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") - fill_in "#{form_prefix}_start_date", with: (target_date).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_start_date", with: target_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(target_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) From 6f98f503feff7c7da423091069a9ed03fcc6c702 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 2 Jun 2025 07:22:10 -0700 Subject: [PATCH 56/94] Renamed reminder schedule field, removed migration that removed reminder_day field, and added first pass at ReminderScheduleService [skip ci] --- app/controllers/organizations_controller.rb | 5 +- app/models/concerns/reminder_scheduleable.rb | 92 ++++++++++++ app/models/organization.rb | 9 +- app/models/partner_group.rb | 23 ++- app/services/reminder_schedule_service.rb | 135 ++++++++++++++++++ app/views/organizations/_details.html.erb | 2 +- ..._add_reminder_schedule_to_organizations.rb | 4 +- ...40715162837_seed_reminder_schedule_data.rb | 8 +- ..._remove_reminder_day_from_organizations.rb | 6 - db/schema.rb | 6 +- spec/factories/organizations.rb | 15 +- spec/factories/partner_groups.rb | 29 ++-- spec/models/organization_spec.rb | 3 +- spec/models/partner_group_spec.rb | 17 +-- 14 files changed, 288 insertions(+), 66 deletions(-) create mode 100644 app/models/concerns/reminder_scheduleable.rb create mode 100644 app/services/reminder_schedule_service.rb delete mode 100644 db/migrate/20240715163348_remove_reminder_day_from_organizations.rb diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 80c734e80d..7c96ce3170 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -13,7 +13,6 @@ def show def edit @organization = current_organization - @organization.get_values_from_reminder_schedule end def update @@ -95,15 +94,13 @@ def organization_params :name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_storage_location, :default_email_text, :reminder_email_text, - :invitation_text, :reminder_schedule, :deadline_day, + :invitation_text, :reminder_schedule_definition, :deadline_day, *ReminderScheduleService::REMINDER_SCHEDULE_FIELDS, :repackage_essentials, :distribute_monthly, :ndbn_member_id, :enable_child_based_requests, :enable_individual_requests, :enable_quantity_based_requests, :ytd_on_distribution_printout, :one_step_partner_invite, :hide_value_columns_on_receipt, :hide_package_column_on_receipt, :signature_for_distribution_pdf, :receive_email_on_requests, - :start_date, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, - :every_nth_month, :include_in_kind_values_in_exported_files, partner_form_fields: [], request_unit_names: [] diff --git a/app/models/concerns/reminder_scheduleable.rb b/app/models/concerns/reminder_scheduleable.rb new file mode 100644 index 0000000000..90d8585543 --- /dev/null +++ b/app/models/concerns/reminder_scheduleable.rb @@ -0,0 +1,92 @@ +module ReminderScheduleable + extend ActiveSupport::Concern + + included do + # Consider prefixing the REMINDER_SCHEDULE_FIELDS to avoid collisions elsewhere? + before_save :save_reminder_schedule_definition + after_save :reset_reminder_schedule_service + + validate :reminder_schedule_is_valid? + validates :deadline_day, numericality: { + only_integer: true, + less_than_or_equal_to: ReminderScheduleService::MAX_DAY_OF_MONTH, + greater_than_or_equal_to: ReminderScheduleService::MIN_DAY_OF_MONTH, + allow_nil: true + } + end + + # For now assume that you won't be setting individual fields. + # You'll only be updating the ReminderScheduleService via saving an object with params. + def every_nth_month = reminder_schedule&.every_nth_month + def every_nth_month=(x) + if reminder_schedule + reminder_schedule.every_nth_month = x + end + end + def start_date = reminder_schedule&.start_date + def start_date=(x) + if reminder_schedule + reminder_schedule.start_date = x + end + end + def by_month_or_week = reminder_schedule&.by_month_or_week + def by_month_or_week=(x) + if reminder_schedule + reminder_schedule.by_month_or_week = x + end + end + def day_of_month = reminder_schedule&.day_of_month + def day_of_month=(x) + if reminder_schedule + reminder_schedule.day_of_month = x + end + end + def day_of_week = reminder_schedule&.day_of_week + def day_of_week=(x) + if reminder_schedule + reminder_schedule.day_of_week = x + end + end + def every_nth_day = reminder_schedule&.every_nth_day + def every_nth_day=(x) + if reminder_schedule + reminder_schedule.every_nth_day = x + end + end + + def reminder_schedule + if reminder_schedule_definition.present? + @reminder_schedule_service ||= ReminderScheduleService.from_ical(reminder_schedule_definition, self) + end + @reminder_schedule_service ||= ReminderScheduleService.new(parent_object: self, start_date: Time.zone.today) + end + + def reminder_schedule_from_params + @reminder_schedule_service ||= ReminderScheduleService.new({ + parent_object: self, + every_nth_month: every_nth_month, + start_date: start_date, + by_month_or_week: by_month_or_week, + day_of_month: day_of_month, + day_of_week: day_of_week, + every_nth_day: every_nth_day, + }) + end + + def save_reminder_schedule_definition + self.reminder_schedule_definition = reminder_schedule_from_params.to_ical + end + + private + + def reminder_schedule_is_valid? + unless reminder_schedule_from_params.valid? + errors.merge!(reminder_schedule_from_params.errors) + end + end + + def reset_reminder_schedule_service + @reminder_schedule_service = nil + end + +end diff --git a/app/models/organization.rb b/app/models/organization.rb index 62ea472129..bff62f1f50 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -22,7 +22,8 @@ # one_step_partner_invite :boolean default(FALSE), not null # partner_form_fields :text default([]), is an Array # receive_email_on_requests :boolean default(FALSE), not null -# reminder_schedule :string +# reminder_day :integer +# reminder_schedule_definition :string # repackage_essentials :boolean default(FALSE), not null # signature_for_distribution_pdf :boolean default(FALSE) # state :string @@ -42,7 +43,7 @@ class Organization < ApplicationRecord DIAPER_APP_LOGO = Rails.public_path.join("img", "humanessentials_logo.png") - include Deadlinable + include ReminderScheduleable # TODO: remove once migration "20250504183911_remove_short_name_from_organizations" has run in production self.ignored_columns += ["short_name"] @@ -95,10 +96,6 @@ def upcoming end end - before_save do - self.reminder_schedule = create_schedule - end - after_create do account_request&.update!(status: "admin_approved") end diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index a4998e0076..80b88d3b20 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -2,27 +2,24 @@ # # Table name: partner_groups # -# id :bigint not null, primary key -# deadline_day :integer -# name :string -# reminder_schedule :string -# send_reminders :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :bigint +# id :bigint not null, primary key +# deadline_day :integer +# name :string +# reminder_day :integer +# reminder_schedule_definition :string +# send_reminders :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :bigint # class PartnerGroup < ApplicationRecord has_paper_trail - include Deadlinable + include ReminderScheduleable belongs_to :organization has_many :partners, dependent: :nullify has_and_belongs_to_many :item_categories - before_save do - self.reminder_schedule = create_schedule - end - validates :name, presence: true, uniqueness: { scope: :organization } validates :deadline_day, presence: true, if: :send_reminders? end diff --git a/app/services/reminder_schedule_service.rb b/app/services/reminder_schedule_service.rb new file mode 100644 index 0000000000..76d3268848 --- /dev/null +++ b/app/services/reminder_schedule_service.rb @@ -0,0 +1,135 @@ +class ReminderScheduleService + MIN_DAY_OF_MONTH = 1 + MAX_DAY_OF_MONTH = 28 + EVERY_NTH_COLLECTION = [["First", 1], ["Second", 2], ["Third", 3], ["Fourth", 4], ["Last", -1]].freeze + DAY_OF_WEEK_COLLECTION = [["Sunday", 0], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]].freeze + EVERY_NTH_MONTH_COLLECTION = [["Monthly", 1], ["Every 2 months", 2], ["Every 3 months", 3], ["Every 4 months", 4], ["Every 5 months", 5], + ["Every 6 months", 6], ["Every 7 months", 7], ["Every 8 months", 8], ["Every 9 months", 9], ["Every 10 months", 10], ["Every 11 months", 11], + ["Every 12 months", 12]].freeze + NTH_TO_WORD_MAP = { + 1 => "First", + 2 => "Second", + 3 => "Third", + 4 => "Fourth", + -1 => "Last" + }.freeze + + # The list of fields which are part of the _deadline_day_fields.html.erb form + REMINDER_SCHEDULE_FIELDS = [ + :every_nth_month, + :start_date, + :by_month_or_week, + :day_of_month, + :day_of_week, + :every_nth_day, + ].freeze + + attr_accessor *ReminderScheduleService::REMINDER_SCHEDULE_FIELDS + attr_accessor :parent_object + + include ActiveModel::Validations + + validate :parent_object_has_deadline_day_field? + validate :every_nth_month_within_range? + validates :start_date, presence: true + validate :start_date_is_valid_date_string? + validates :by_month_or_week, inclusion: {in: %w[day_of_month day_of_week]} + validates :day_of_month, if: -> { @by_month_or_week == "day_of_month" }, presence: true + validate :day_of_month_is_within_range?, if: -> { @by_month_or_week == "day_of_month" } + validate :deadline_not_on_reminder_date?, if: -> { @by_month_or_week == "day_of_month" && @deadline_day.present? } + validates :day_of_week, if: -> { @by_month_or_week == "day_of_week" }, inclusion: {in: %w[0 1 2 3 4 5 6]} + validates :every_nth_day, if: -> { @by_month_or_week == "day_of_week" }, inclusion: {in: %w[1 2 3 4 -1]} + + def initialize(parameter_hash) + @parent_object = parameter_hash[:parent_object] + @every_nth_month = parameter_hash[:every_nth_month] + @start_date = parameter_hash[:start_date] + if !@start_date + @start_date = Time.zone.now.to_date.to_s + end + @by_month_or_week = parameter_hash[:by_month_or_week] + @day_of_month = parameter_hash[:day_of_month] + @day_of_week = parameter_hash[:day_of_week] + @every_nth_day = parameter_hash[:every_nth_day] + end + + def self.from_ical(ical, parent_object) + if ical.blank? + return + end + schedule = IceCube::Schedule.from_ical(ical) + rule = schedule.recurrence_rules.first.instance_values + if rule.blank? + return + end + day_of_month = rule["validations"][:day_of_month]&.first&.value + + ReminderScheduleService.new({ + parent_object: parent_object, + every_nth_month: rule["validations"][:interval]&.first&.interval, + start_date: schedule.start_time, + by_month_or_week: day_of_month ? "day_of_month" : "day_of_week", + day_of_month: day_of_month, + day_of_week: rule["validations"][:day_of_week]&.first&.day, + every_nth_day: rule["validations"][:day_of_week]&.first&.occ, + }) + end + + def to_icecube_schedule + unless valid? + return nil + end + schedule = IceCube::Schedule.new(start_date.respond_to?(:strftime) ? start_date : Time.zone.parse(start_date)) + if by_month_or_week == "day_of_month" + schedule.add_recurrence_rule(IceCube::Rule.monthly(every_nth_month).day_of_month(day_of_month.to_i)) + else + schedule.add_recurrence_rule(IceCube::Rule.monthly(every_nth_month).day_of_week(day_of_week.to_i => [every_nth_day.to_i])) + end + schedule + end + + def to_ical + to_icecube_schedule&.to_ical + end + + def show_description + to_icecube_schedule&.recurrence_rules&.first.to_s + end + + private + + def parent_object_has_deadline_day_field? + unless parent_object.respond_to?(:deadline_day) + errors.add(:parent_object, "ReminderScheduleService expects to be associated with an object that has the deadline_day field") + end + end + + def every_nth_month_within_range? + if every_nth_month.to_i < EVERY_NTH_MONTH_COLLECTION.first.last || every_nth_month.to_i > EVERY_NTH_MONTH_COLLECTION.last.last + errors.add(:every_nth_month, "Monthly frequence must be between #{EVERY_NTH_MONTH_COLLECTION.first.first} and #{EVERY_NTH_MONTH_COLLECTION.first.first}") + end + end + + def start_date_is_valid_date_string? + unless start_date.respond_to?(:strftime) || Time.zone.parse(start_date) + errors.add(:start_date, "Start date must be a valid date string") + end + end + + def day_of_month_is_within_range? + # IceCube converts negative or zero days to valid days (e.g. -1 becomes the last day of the month, 0 becomes 1) + # The minimum check should no longer be necessary, but keeping it in case IceCube changes + if day_of_month.to_i < MIN_DAY_OF_MONTH || day_of_month.to_i > MAX_DAY_OF_MONTH + errors.add(:day_of_month, "Reminder day must be between #{MIN_DAY_OF_MONTH} and #{MAX_DAY_OF_MONTH}") + end + end + + # TODO: Consider reworking this to validate the IceCube schedule that gets generated, so it checks both day_of_month and day_of_week + # schedules + def deadline_not_on_reminder_date? + if day_of_month.to_i == parent_object.deadline_day.to_i + errors.add(:day_of_month, "Reminder day must not be the same as deadline day") + end + end + +end \ No newline at end of file diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb index 72994a122b..be0e41213b 100644 --- a/app/views/organizations/_details.html.erb +++ b/app/views/organizations/_details.html.erb @@ -174,7 +174,7 @@
Default reminder day (day of month an email reminder to submit Requests is sent to Partners)

<%= fa_icon "calendar" %> - <%= @organization.reminder_schedule.blank? ? 'Not defined' : @organization.show_description(@organization.reminder_schedule) %> + <%= @organization.reminder_schedule.blank? ? 'Not defined' : @organization.reminder_schedule.show_description %>

diff --git a/db/migrate/20240715155823_add_reminder_schedule_to_organizations.rb b/db/migrate/20240715155823_add_reminder_schedule_to_organizations.rb index 4da938f248..7e6f4d7166 100644 --- a/db/migrate/20240715155823_add_reminder_schedule_to_organizations.rb +++ b/db/migrate/20240715155823_add_reminder_schedule_to_organizations.rb @@ -1,6 +1,6 @@ class AddReminderScheduleToOrganizations < ActiveRecord::Migration[7.1] def change - add_column :organizations, :reminder_schedule, :string - add_column :partner_groups, :reminder_schedule, :string + add_column :organizations, :reminder_schedule_definition, :string + add_column :partner_groups, :reminder_schedule_definition, :string end end diff --git a/db/migrate/20240715162837_seed_reminder_schedule_data.rb b/db/migrate/20240715162837_seed_reminder_schedule_data.rb index 962de0648f..165cab5cd9 100644 --- a/db/migrate/20240715162837_seed_reminder_schedule_data.rb +++ b/db/migrate/20240715162837_seed_reminder_schedule_data.rb @@ -2,15 +2,15 @@ class SeedReminderScheduleData < ActiveRecord::Migration[7.1] def change for o in Organization.all if o.reminder_day.present? - reminder_schedule = o.convert_to_reminder_schedule(o.reminder_day) - o.update_column(:reminder_schedule, reminder_schedule) + reminder_schedule = ReminderScheduleService.new({every_nth_month: "1", by_month_or_week: "day_of_month", day_of_month: o.reminder_day}) + o.update_column(:reminder_schedule_definition, reminder_schedule.to_ical) end end for pg in PartnerGroup.all if pg.reminder_day.present? - reminder_schedule = pg.convert_to_reminder_schedule(pg.reminder_day) - pg.update_column(:reminder_schedule, reminder_schedule) + reminder_schedule = ReminderScheduleService.new({every_nth_month: "1", by_month_or_week: "day_of_month", day_of_month: pg.reminder_day}) + pg.update_column(:reminder_schedule_definition, reminder_schedule.to_ical) end end end diff --git a/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb b/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb deleted file mode 100644 index df697e0c90..0000000000 --- a/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb +++ /dev/null @@ -1,6 +0,0 @@ -class RemoveReminderDayFromOrganizations < ActiveRecord::Migration[7.1] - def change - safety_assured { remove_column :organizations, :reminder_day, :integer } - safety_assured { remove_column :partner_groups, :reminder_day, :integer } - end -end diff --git a/db/schema.rb b/db/schema.rb index 079c3bb649..b7e549410d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -494,8 +494,9 @@ t.boolean "hide_package_column_on_receipt", default: false t.boolean "signature_for_distribution_pdf", default: false t.boolean "receive_email_on_requests", default: false, null: false - t.string "reminder_schedule" t.boolean "include_in_kind_values_in_exported_files", default: false, null: false + t.integer "reminder_day" + t.string "reminder_schedule_definition" t.index ["latitude", "longitude"], name: "index_organizations_on_latitude_and_longitude" t.index ["short_name"], name: "index_organizations_on_short_name" end @@ -514,7 +515,8 @@ t.datetime "updated_at", null: false t.boolean "send_reminders", default: false, null: false t.integer "deadline_day" - t.string "reminder_schedule" + t.integer "reminder_day" + t.string "reminder_schedule_definition" t.index ["name", "organization_id"], name: "index_partner_groups_on_name_and_organization_id", unique: true t.index ["organization_id"], name: "index_partner_groups_on_organization_id" t.check_constraint "deadline_day <= 28", name: "deadline_day_of_month_check" diff --git a/spec/factories/organizations.rb b/spec/factories/organizations.rb index c5cdca7f7a..1a346800bf 100644 --- a/spec/factories/organizations.rb +++ b/spec/factories/organizations.rb @@ -22,7 +22,8 @@ # one_step_partner_invite :boolean default(FALSE), not null # partner_form_fields :text default([]), is an Array # receive_email_on_requests :boolean default(FALSE), not null -# reminder_schedule :string +# reminder_day :integer +# reminder_schedule_definition :string # repackage_essentials :boolean default(FALSE), not null # signature_for_distribution_pdf :boolean default(FALSE) # state :string @@ -43,9 +44,11 @@ skip_items { false } end - recurrence_schedule = IceCube::Schedule.new - recurrence_schedule.add_recurrence_rule IceCube::Rule.monthly(1).day_of_month(10) - recurrence_schedule_ical = recurrence_schedule.to_ical + reminder_schedule_definition = ReminderScheduleService.new({ + every_nth_month: "1", + by_month_or_week: "day_of_month", + day_of_month: 10 + }) sequence(:name) { |n| "Essentials Bank #{n}" } # 037000863427 sequence(:email) { |n| "email#{n}@example.com" } # 037000863427 sequence(:url) { |n| "https://organization#{n}.org" } # 037000863427 @@ -53,12 +56,12 @@ city { 'Front Royal' } state { 'VA' } zipcode { '22630' } - reminder_schedule { recurrence_schedule_ical } + reminder_schedule_definition { reminder_schedule_definition.to_ical } logo { Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/logo.jpg"), "image/jpeg") } trait :without_deadlines do - reminder_schedule { nil } + reminder_schedule_definition { nil } deadline_day { nil } end diff --git a/spec/factories/partner_groups.rb b/spec/factories/partner_groups.rb index 9570bb0b37..685dc94308 100644 --- a/spec/factories/partner_groups.rb +++ b/spec/factories/partner_groups.rb @@ -2,28 +2,31 @@ # # Table name: partner_groups # -# id :bigint not null, primary key -# deadline_day :integer -# name :string -# reminder_schedule :string -# send_reminders :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :bigint +# id :bigint not null, primary key +# deadline_day :integer +# name :string +# reminder_day :integer +# reminder_schedule_definition :string +# send_reminders :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :bigint # FactoryBot.define do - recurrence_schedule = IceCube::Schedule.new - recurrence_schedule.add_recurrence_rule IceCube::Rule.monthly(1).day_of_month(10) - recurrence_schedule_ical = recurrence_schedule.to_ical + reminder_schedule_definition = ReminderScheduleService.new({ + every_nth_month: "1", + by_month_or_week: "day_of_month", + day_of_month: 10 + }) factory :partner_group do sequence(:name) { |n| "Group #{n}" } organization { Organization.try(:first) || create(:organization) } - reminder_schedule { recurrence_schedule_ical } + reminder_schedule_definition { reminder_schedule_definition.to_ical } trait :without_deadlines do - reminder_schedule { nil } + reminder_schedule_definition { nil } deadline_day { nil } end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index be41734ba8..23f9d8f3bb 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -22,7 +22,8 @@ # one_step_partner_invite :boolean default(FALSE), not null # partner_form_fields :text default([]), is an Array # receive_email_on_requests :boolean default(FALSE), not null -# reminder_schedule :string +# reminder_day :integer +# reminder_schedule_definition :string # repackage_essentials :boolean default(FALSE), not null # signature_for_distribution_pdf :boolean default(FALSE) # state :string diff --git a/spec/models/partner_group_spec.rb b/spec/models/partner_group_spec.rb index 195bed6e2c..301d332a3f 100644 --- a/spec/models/partner_group_spec.rb +++ b/spec/models/partner_group_spec.rb @@ -2,14 +2,15 @@ # # Table name: partner_groups # -# id :bigint not null, primary key -# deadline_day :integer -# name :string -# reminder_schedule :string -# send_reminders :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :bigint +# id :bigint not null, primary key +# deadline_day :integer +# name :string +# reminder_day :integer +# reminder_schedule_definition :string +# send_reminders :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :bigint # RSpec.describe PartnerGroup, type: :model do describe 'associations' do From 77f3f758afbd386beda7edc6db3aa6d3feb752a0 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Fri, 13 Jun 2025 09:35:07 -0700 Subject: [PATCH 57/94] Removed validations from ReminderScheduleService that rely on the parent object --- app/services/reminder_schedule_service.rb | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/app/services/reminder_schedule_service.rb b/app/services/reminder_schedule_service.rb index 76d3268848..c5fc802052 100644 --- a/app/services/reminder_schedule_service.rb +++ b/app/services/reminder_schedule_service.rb @@ -25,23 +25,19 @@ class ReminderScheduleService ].freeze attr_accessor *ReminderScheduleService::REMINDER_SCHEDULE_FIELDS - attr_accessor :parent_object include ActiveModel::Validations - validate :parent_object_has_deadline_day_field? validate :every_nth_month_within_range? validates :start_date, presence: true validate :start_date_is_valid_date_string? validates :by_month_or_week, inclusion: {in: %w[day_of_month day_of_week]} validates :day_of_month, if: -> { @by_month_or_week == "day_of_month" }, presence: true validate :day_of_month_is_within_range?, if: -> { @by_month_or_week == "day_of_month" } - validate :deadline_not_on_reminder_date?, if: -> { @by_month_or_week == "day_of_month" && @deadline_day.present? } validates :day_of_week, if: -> { @by_month_or_week == "day_of_week" }, inclusion: {in: %w[0 1 2 3 4 5 6]} validates :every_nth_day, if: -> { @by_month_or_week == "day_of_week" }, inclusion: {in: %w[1 2 3 4 -1]} def initialize(parameter_hash) - @parent_object = parameter_hash[:parent_object] @every_nth_month = parameter_hash[:every_nth_month] @start_date = parameter_hash[:start_date] if !@start_date @@ -53,7 +49,7 @@ def initialize(parameter_hash) @every_nth_day = parameter_hash[:every_nth_day] end - def self.from_ical(ical, parent_object) + def self.from_ical(ical) if ical.blank? return end @@ -65,7 +61,6 @@ def self.from_ical(ical, parent_object) day_of_month = rule["validations"][:day_of_month]&.first&.value ReminderScheduleService.new({ - parent_object: parent_object, every_nth_month: rule["validations"][:interval]&.first&.interval, start_date: schedule.start_time, by_month_or_week: day_of_month ? "day_of_month" : "day_of_week", @@ -98,12 +93,6 @@ def show_description private - def parent_object_has_deadline_day_field? - unless parent_object.respond_to?(:deadline_day) - errors.add(:parent_object, "ReminderScheduleService expects to be associated with an object that has the deadline_day field") - end - end - def every_nth_month_within_range? if every_nth_month.to_i < EVERY_NTH_MONTH_COLLECTION.first.last || every_nth_month.to_i > EVERY_NTH_MONTH_COLLECTION.last.last errors.add(:every_nth_month, "Monthly frequence must be between #{EVERY_NTH_MONTH_COLLECTION.first.first} and #{EVERY_NTH_MONTH_COLLECTION.first.first}") @@ -124,12 +113,4 @@ def day_of_month_is_within_range? end end - # TODO: Consider reworking this to validate the IceCube schedule that gets generated, so it checks both day_of_month and day_of_week - # schedules - def deadline_not_on_reminder_date? - if day_of_month.to_i == parent_object.deadline_day.to_i - errors.add(:day_of_month, "Reminder day must not be the same as deadline day") - end - end - end \ No newline at end of file From d4162b7593599495ddb9045ee3419e6f6903fd28 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sun, 15 Jun 2025 09:35:37 -0700 Subject: [PATCH 58/94] Minor change to make conditional cleaner --- app/views/organizations/_details.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb index be0e41213b..ddfc8eeffb 100644 --- a/app/views/organizations/_details.html.erb +++ b/app/views/organizations/_details.html.erb @@ -174,7 +174,7 @@
Default reminder day (day of month an email reminder to submit Requests is sent to Partners)

<%= fa_icon "calendar" %> - <%= @organization.reminder_schedule.blank? ? 'Not defined' : @organization.reminder_schedule.show_description %> + <%= @organization.reminder_schedule&.show_description || 'Not defined' %>

From 28135643e186151ebb5f253d297ef0f212ebe656 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sun, 15 Jun 2025 09:52:13 -0700 Subject: [PATCH 59/94] Factored out ReminderScheduleable concern, updated _deadline_day_fields form to use a ReminderScheduleService object instead of an ActiveRecord object [skip ci] --- app/controllers/organizations_controller.rb | 9 +- app/models/organization.rb | 35 ++++- app/models/partner_group.rb | 1 - app/services/reminder_schedule_service.rb | 12 +- app/views/admin/organizations/edit.html.erb | 2 +- app/views/admin/organizations/new.html.erb | 2 +- app/views/organizations/edit.html.erb | 2 +- .../shared/_deadline_day_fields.html.erb | 132 +++++++++--------- 8 files changed, 122 insertions(+), 73 deletions(-) diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 7c96ce3170..dc269941d6 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -17,6 +17,7 @@ def edit def update @organization = current_organization + @organization.reminder_schedule.assign_attributes(reminder_schedule_params) if OrganizationUpdateService.update(@organization, organization_params) redirect_to organization_path, notice: "Updated your organization!" else @@ -94,7 +95,7 @@ def organization_params :name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_storage_location, :default_email_text, :reminder_email_text, - :invitation_text, :reminder_schedule_definition, :deadline_day, *ReminderScheduleService::REMINDER_SCHEDULE_FIELDS, + :invitation_text, :reminder_schedule_definition, :deadline_day, :repackage_essentials, :distribute_monthly, :ndbn_member_id, :enable_child_based_requests, :enable_individual_requests, :enable_quantity_based_requests, @@ -107,6 +108,12 @@ def organization_params ) end + def reminder_schedule_params + request_type_formatter(params) + + params.require(:organization).require(:reminder_schedule_service).permit([*ReminderScheduleService::REMINDER_SCHEDULE_FIELDS]) + end + def request_type_formatter(params) if params[:organization][:enable_individual_requests] == "false" params[:organization][:enable_child_based_requests] = false diff --git a/app/models/organization.rb b/app/models/organization.rb index bff62f1f50..689ee68c14 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -43,8 +43,6 @@ class Organization < ApplicationRecord DIAPER_APP_LOGO = Rails.public_path.join("img", "humanessentials_logo.png") - include ReminderScheduleable - # TODO: remove once migration "20250504183911_remove_short_name_from_organizations" has run in production self.ignored_columns += ["short_name"] @@ -54,6 +52,13 @@ class Organization < ApplicationRecord validate :correct_logo_mime_type validate :some_request_type_enabled validate :logo_size_check, if: proc { |org| org.logo.attached? } + validates :deadline_day, numericality: { + only_integer: true, + less_than_or_equal_to: ReminderScheduleService::MAX_DAY_OF_MONTH, + greater_than_or_equal_to: ReminderScheduleService::MIN_DAY_OF_MONTH, + allow_nil: true + } + validate :reminder_schedule_is_empty_or_valid? belongs_to :account_request, optional: true belongs_to :ndbn_member, class_name: 'NDBNMember', optional: true @@ -96,6 +101,8 @@ def upcoming end end + before_save :save_reminder_schedule_definition + after_create do account_request&.update!(status: "admin_approved") end @@ -240,6 +247,30 @@ def display_last_distribution_date distribution.nil? ? "No distributions" : distribution[:issued_at].strftime("%F") end + def reminder_schedule + if reminder_schedule_definition.present? + @reminder_schedule_service ||= ReminderScheduleService.from_ical(reminder_schedule_definition) + end + @reminder_schedule_service ||= ReminderScheduleService.new(start_date: Time.zone.today) + end + + def save_reminder_schedule_definition + self.reminder_schedule_definition = reminder_schedule.to_ical + @reminder_schedule_service = nil + end + + def reminder_schedule_is_empty_or_valid? + unless reminder_schedule.no_fields_filled_out? || (reminder_schedule.valid? && deadline_not_on_reminder_date?) + errors.merge!(reminder_schedule.errors) + end + end + + def deadline_not_on_reminder_date? + if reminder_schedule.day_of_month.to_i == deadline_day.to_i + errors.add(:day_of_month, "Reminder day must not be the same as deadline day") + end + end + private def correct_logo_mime_type diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index 80b88d3b20..3bc12cd9a2 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -14,7 +14,6 @@ # class PartnerGroup < ApplicationRecord has_paper_trail - include ReminderScheduleable belongs_to :organization has_many :partners, dependent: :nullify diff --git a/app/services/reminder_schedule_service.rb b/app/services/reminder_schedule_service.rb index c5fc802052..7ee050d24c 100644 --- a/app/services/reminder_schedule_service.rb +++ b/app/services/reminder_schedule_service.rb @@ -70,6 +70,12 @@ def self.from_ical(ical) }) end + def assign_attributes(attrs) + attrs.each_pair do |attr, value| + instance_variable_set("@#{attr}", value) + end + end + def to_icecube_schedule unless valid? return nil @@ -91,11 +97,15 @@ def show_description to_icecube_schedule&.recurrence_rules&.first.to_s end + def no_fields_filled_out? + every_nth_month.nil? && by_month_or_week.nil? && day_of_month.nil? && day_of_week.nil? && every_nth_day.nil? + end + private def every_nth_month_within_range? if every_nth_month.to_i < EVERY_NTH_MONTH_COLLECTION.first.last || every_nth_month.to_i > EVERY_NTH_MONTH_COLLECTION.last.last - errors.add(:every_nth_month, "Monthly frequence must be between #{EVERY_NTH_MONTH_COLLECTION.first.first} and #{EVERY_NTH_MONTH_COLLECTION.first.first}") + errors.add(:every_nth_month, "Monthly frequence must be between #{EVERY_NTH_MONTH_COLLECTION.first.first} and #{EVERY_NTH_MONTH_COLLECTION.last.first}") end end diff --git a/app/views/admin/organizations/edit.html.erb b/app/views/admin/organizations/edit.html.erb index 14cdbcee9d..c7d846e3cb 100644 --- a/app/views/admin/organizations/edit.html.erb +++ b/app/views/admin/organizations/edit.html.erb @@ -59,7 +59,7 @@ <%= f.input_field :zipcode, class: "form-control", placeholder: "zipcode" %> <% end %> - <%= render 'shared/deadline_day_fields', f: f %> + <%= render 'shared/deadline_day_fields', parent_form: f, parent_object: @organization %> <%= f.input :intake_location, :collection => @organization.storage_locations.active.alphabetized, :label_method => :name, :value_method => :id, :label => "Default Intake Location", :include_blank => true, wrapper: :input_group %> diff --git a/app/views/admin/organizations/new.html.erb b/app/views/admin/organizations/new.html.erb index bda5613da6..c6157177d6 100644 --- a/app/views/admin/organizations/new.html.erb +++ b/app/views/admin/organizations/new.html.erb @@ -47,7 +47,7 @@ <%= f.input :city %> <%= f.input :state, collection: us_states, class: "form-control", placeholder: "state" %> <%= f.input :zipcode %> - <%= render 'shared/deadline_day_fields', f: f %> + <%= render 'shared/deadline_day_fields', parent_form: f, parent_object: @organization %> <%= f.simple_fields_for :account_request do |account_request| %> <%= account_request.input :ndbn_member, label: 'NDBN Membership', wrapper: :input_group do %> <%= account_request.association :ndbn_member, label_method: :full_name, value_method: :id, label: false %> diff --git a/app/views/organizations/edit.html.erb b/app/views/organizations/edit.html.erb index 4e44a78e43..eb9ed752d6 100644 --- a/app/views/organizations/edit.html.erb +++ b/app/views/organizations/edit.html.erb @@ -151,7 +151,7 @@

Other emails

- <%= render 'shared/deadline_day_fields', f: f %> + <%= render 'shared/deadline_day_fields', parent_form: f, parent_object: current_organization %> <% default_reminder_email_text_hint = "You can use the variable %{partner_name} to include the partner's name in the message." %> <%= f.input :reminder_email_text, label: "Additional text for reminder email", hint: default_reminder_email_text_hint.html_safe do %> <%= f.rich_text_area :reminder_email_text, placeholder: 'Enter reminder email content...' %> diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index 4b153c622a..d7c9896b04 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -1,76 +1,78 @@
- <%= f.label :every_nth_month, 'How frequently should reminders be sent (e.g. "monthly", "every 3 months", etc.)?' %> - <%= f.input :every_nth_month, - collection: Deadlinable::EVERY_NTH_MONTH_COLLECTION, - class: "form-control", - label: false, - show_blank: true, - default: 1, - input_html: {style: 'width: 200px', "data-deadline-day-target" => "everyNthMonth", "data-action" => "deadline-day#sourceChange"} %> + <%= parent_form.simple_fields_for parent_object.reminder_schedule do |f| %> + <%= f.label :every_nth_month, 'How frequently should reminders be sent (e.g. "monthly", "every 3 months", etc.)?' %> + <%= f.input :every_nth_month, + collection: ReminderScheduleService::EVERY_NTH_MONTH_COLLECTION, + class: "form-control", + label: false, + show_blank: true, + default: 1, + input_html: {style: 'width: 200px', "data-deadline-day-target" => "everyNthMonth", "data-action" => "deadline-day#sourceChange"} %> - <%= f.label :start_date, 'Reminder start being sent after:' %> - <%= f.input :start_date, - as: :date, - label: false, - html5: true, - wrapper: :input_group, - input_html: {"data-deadline-day-target" => "startDate", "data-action" => "deadline-day#sourceChange"} %> + <%= f.label :start_date, 'Reminder start being sent after:' %> + <%= f.input :start_date, + as: :date, + label: false, + html5: true, + wrapper: :input_group, + input_html: {"data-deadline-day-target" => "startDate", "data-action" => "deadline-day#sourceChange"} %> - <%= f.label :by_month_or_week, 'Send reminders on a specific day of the month (e.g. "the 5th") or a day of the week (eg "the first Tuesday")?' %> -
- <%= f.radio_button :by_month_or_week, - 'day_of_month', - label: 'Day of Month', - data: { 'deadline-day-target': 'byDayOfMonth', 'action': 'deadline-day#monthOrWeekChanged deadline-day#sourceChange' } %> - <%= f.label :by_month_or_week_day_of_month, 'Day of Month' %> -
- <%= f.radio_button :by_month_or_week, - 'day_of_week', - label: 'Day of the Week', - data: { 'deadline-day-target': 'byDayOfWeek', 'action': 'deadline-day#monthOrWeekChanged deadline-day#sourceChange' } %> - <%= f.label :by_month_or_week_day_of_week, 'Day of the Week' %> + <%= f.label :by_month_or_week, 'Send reminders on a specific day of the month (e.g. "the 5th") or a day of the week (eg "the first Tuesday")?' %> +
+ <%= f.radio_button :by_month_or_week, + 'day_of_month', + label: 'Day of Month', + data: { 'deadline-day-target': 'byDayOfMonth', 'action': 'deadline-day#monthOrWeekChanged deadline-day#sourceChange' } %> + <%= f.label :by_month_or_week_day_of_month, 'Day of Month' %> +
+ <%= f.radio_button :by_month_or_week, + 'day_of_week', + label: 'Day of the Week', + data: { 'deadline-day-target': 'byDayOfWeek', 'action': 'deadline-day#monthOrWeekChanged deadline-day#sourceChange' } %> + <%= f.label :by_month_or_week_day_of_week, 'Day of the Week' %> -
- <%= f.input :day_of_month, as: :integer, wrapper: :input_group, label: 'Reminder date' do %> - - <%= f.number_field :day_of_month, - min: Deadlinable::MIN_DAY_OF_MONTH, - max: Deadlinable::MAX_DAY_OF_MONTH, - class: "form-control", - placeholder: "Reminder day", - data: { 'deadline-day-target': 'dayOfMonth', 'action': 'deadline-day#sourceChange' } %> - <% end %> -
- -
- <%= f.label :every_nth_day, 'Reminder day of the week' %> -
- <%= f.input :every_nth_day, - collection: Deadlinable::EVERY_NTH_COLLECTION, - class: "form-control", - label: false, - input_html: {"data-deadline-day-target" => "everyNthDay", "data-action" => "deadline-day#sourceChange"} %> +
+ <%= f.input :day_of_month, as: :integer, wrapper: :input_group, label: 'Reminder date' do %> + + <%= f.number_field :day_of_month, + min: ReminderScheduleService::MIN_DAY_OF_MONTH, + max: ReminderScheduleService::MAX_DAY_OF_MONTH, + class: "form-control", + placeholder: "Reminder day", + data: { 'deadline-day-target': 'dayOfMonth', 'action': 'deadline-day#sourceChange' } %> + <% end %> +
- <%= f.input :day_of_week, - collection: Deadlinable::DAY_OF_WEEK_COLLECTION, +
+ <%= f.label :every_nth_day, 'Reminder day of the week' %> +
+ <%= f.input :every_nth_day, + collection: ReminderScheduleService::EVERY_NTH_COLLECTION, class: "form-control", label: false, - show_blank: true, - default: 1, - input_html: {style: 'width: 200px', "data-deadline-day-target" => "dayOfWeek", "data-action" => "deadline-day#sourceChange"} %> -
-
- + input_html: {"data-deadline-day-target" => "everyNthDay", "data-action" => "deadline-day#sourceChange"} %> - <%= f.input :deadline_day, wrapper: :input_group, wrapper_html: { min: 0, max: 28 }, label: 'Default deadline day (final day of month to submit Requests)' do %> - - <%= f.number_field :deadline_day, - min: Deadlinable::MIN_DAY_OF_MONTH, - max: Deadlinable::MAX_DAY_OF_MONTH, - class: "form-control", - placeholder: "Deadline day", - data: {"deadline-day-target": "deadlineDay", "action": "deadline-day#sourceChange"} %> + <%= f.input :day_of_week, + collection: ReminderScheduleService::DAY_OF_WEEK_COLLECTION, + class: "form-control", + label: false, + show_blank: true, + default: 1, + input_html: {style: 'width: 200px', "data-deadline-day-target" => "dayOfWeek", "data-action" => "deadline-day#sourceChange"} %> +
+
+ <% end %> - + + <%= parent_form.input :deadline_day, wrapper: :input_group, wrapper_html: { min: 0, max: 28 }, label: 'Default deadline day (final day of month to submit Requests)' do %> + + <%= parent_form.number_field :deadline_day, + min: ReminderScheduleService::MIN_DAY_OF_MONTH, + max: ReminderScheduleService::MAX_DAY_OF_MONTH, + class: "form-control", + placeholder: "Deadline day", + data: {"deadline-day-target": "deadlineDay", "action": "deadline-day#sourceChange"} %> + <% end %> +
From dcdbe51aca3388ea96df99aeaaead90c6bc8887c Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sat, 28 Jun 2025 12:37:08 -0700 Subject: [PATCH 60/94] Slightly reworked how attributes are assigned to the reminder_schedule --- app/services/reminder_schedule_service.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/services/reminder_schedule_service.rb b/app/services/reminder_schedule_service.rb index 7ee050d24c..ded3b22dcb 100644 --- a/app/services/reminder_schedule_service.rb +++ b/app/services/reminder_schedule_service.rb @@ -70,10 +70,12 @@ def self.from_ical(ical) }) end + def []=(key, val) + self.send("#{key}=", val) + end + def assign_attributes(attrs) - attrs.each_pair do |attr, value| - instance_variable_set("@#{attr}", value) - end + attrs.each { |key, val| self[key] = val } end def to_icecube_schedule From 1916d37db261c12137d67c4b8b2807d18021ceec Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sat, 28 Jun 2025 14:18:23 -0700 Subject: [PATCH 61/94] Updated partner groups and admin organizations controller to use the new ReminderScheduleService object --- .../admin/organizations_controller.rb | 8 +++-- app/controllers/organizations_controller.rb | 2 -- app/controllers/partner_groups_controller.rb | 10 ++++-- app/models/partner_group.rb | 33 +++++++++++++++++++ .../partners/_partner_groups_table.html.erb | 2 +- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb index bc1818ec99..20c4c31ba9 100644 --- a/app/controllers/admin/organizations_controller.rb +++ b/app/controllers/admin/organizations_controller.rb @@ -16,7 +16,6 @@ def index def new @organization = Organization.new - @organization.get_values_from_reminder_schedule account_request = params[:token] && AccountRequest.get_by_identity_token(params[:token]) @user = User.new @@ -33,6 +32,7 @@ def new def create @user = User.new(user_params) @organization = Organization.new(organization_params) + @organization.reminder_schedule.assign_attributes(reminder_schedule_params) if @organization.save Organization.seed_items(@organization) UserInviteService.invite(name: user_params[:name], @@ -72,10 +72,14 @@ def destroy def organization_params params.require(:organization) .permit(:name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_email_text, :account_request_id, - :start_date, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month, :deadline_day, + :reminder_schedule_definition, :deadline_day, users_attributes: %i(name email organization_admin), account_request_attributes: %i(ndbn_member_id id)) end + def reminder_schedule_params + params.require(:organization).require(:reminder_schedule_service).permit([*ReminderScheduleService::REMINDER_SCHEDULE_FIELDS]) + end + def user_params params.require(:organization).require(:user).permit(:name, :email) end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index dc269941d6..0a02d9745d 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -109,8 +109,6 @@ def organization_params end def reminder_schedule_params - request_type_formatter(params) - params.require(:organization).require(:reminder_schedule_service).permit([*ReminderScheduleService::REMINDER_SCHEDULE_FIELDS]) end diff --git a/app/controllers/partner_groups_controller.rb b/app/controllers/partner_groups_controller.rb index f0769ad902..29a430c13e 100644 --- a/app/controllers/partner_groups_controller.rb +++ b/app/controllers/partner_groups_controller.rb @@ -9,6 +9,7 @@ def new def create @partner_group = current_organization.partner_groups.new(partner_group_params) + @partner_group.reminder_schedule.assign_attributes(reminder_schedule_params) if @partner_group.save # Redirect to groups tab in Partner page. redirect_to partners_path + "#nav-partner-groups", notice: "Partner group added!" @@ -22,12 +23,12 @@ def create def edit @partner_group = current_organization.partner_groups.find(params[:id]) set_items_categories - @partner_group.get_values_from_reminder_schedule @item_categories = current_organization.item_categories end def update @partner_group = current_organization.partner_groups.find(params[:id]) + @partner_group.reminder_schedule.assign_attributes(reminder_schedule_params) if @partner_group.update(partner_group_params) redirect_to partners_path + "#nav-partner-groups", notice: "Partner group edited!" else @@ -55,8 +56,11 @@ def set_partner_group end def partner_group_params - params.require(:partner_group).permit(:name, :send_reminders, :reminder_schedule, - :deadline_day, :start_date, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month, item_category_ids: []) + params.require(:partner_group).permit(:name, :send_reminders, :reminder_schedule_definition, :deadline_day, item_category_ids: []) + end + + def reminder_schedule_params + params.require(:partner_group).require(:reminder_schedule_service).permit([*ReminderScheduleService::REMINDER_SCHEDULE_FIELDS]) end def set_items_categories diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index 3bc12cd9a2..0a45bf12ae 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -21,4 +21,37 @@ class PartnerGroup < ApplicationRecord validates :name, presence: true, uniqueness: { scope: :organization } validates :deadline_day, presence: true, if: :send_reminders? + validates :deadline_day, numericality: { + only_integer: true, + less_than_or_equal_to: ReminderScheduleService::MAX_DAY_OF_MONTH, + greater_than_or_equal_to: ReminderScheduleService::MIN_DAY_OF_MONTH, + allow_nil: true + } + validate :reminder_schedule_is_empty_or_valid? + + before_save :save_reminder_schedule_definition + + def reminder_schedule + if reminder_schedule_definition.present? + @reminder_schedule_service ||= ReminderScheduleService.from_ical(reminder_schedule_definition) + end + @reminder_schedule_service ||= ReminderScheduleService.new(start_date: Time.zone.today) + end + + def save_reminder_schedule_definition + self.reminder_schedule_definition = reminder_schedule.to_ical + @reminder_schedule_service = nil + end + + def reminder_schedule_is_empty_or_valid? + unless reminder_schedule.no_fields_filled_out? || (reminder_schedule.valid? && deadline_not_on_reminder_date?) + errors.merge!(reminder_schedule.errors) + end + end + + def deadline_not_on_reminder_date? + if reminder_schedule.day_of_month.to_i == deadline_day.to_i + errors.add(:day_of_month, "Reminder day must not be the same as deadline day") + end + end end diff --git a/app/views/partners/_partner_groups_table.html.erb b/app/views/partners/_partner_groups_table.html.erb index da4c9ea4ec..d51b081d8f 100644 --- a/app/views/partners/_partner_groups_table.html.erb +++ b/app/views/partners/_partner_groups_table.html.erb @@ -46,7 +46,7 @@ <% if pg.send_reminders %> <% if pg.reminder_schedule.present? %> - Reminder emails are sent <%= pg.show_description(pg.reminder_schedule) %>. + Reminder emails are sent <%= pg.reminder_schedule&.show_description %>.
<% end %> Deadlines are the <%= pg.deadline_day.ordinalize %> after the reminder. From 1a1bd612a18c8ae814ff4b38f12f8f5201a3929d Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sun, 29 Jun 2025 07:04:32 -0700 Subject: [PATCH 62/94] Updated deadline day fields tests to only fill out the form with dates between 1 and 28 --- .../deadline_day_fields_shared_example.rb | 109 ++++++++++++------ 1 file changed, 73 insertions(+), 36 deletions(-) diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 56dedc2bc1..0d89b2bd27 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -81,6 +81,28 @@ end describe "calculates the reminder and deadline dates" do + + # The reminder day (the #{form_prefix}_day_of_month field ) has to be less than or equal to 28. + # These functions are implemented to calculate dates prior or after @now that do not fall on a + # date with a day greater than 28. + def safe_add_days( date, num ) + result = date += num.days + if result.day > 28 + result = result.change({day: num}) + result += 1.month + end + result + end + + def safe_subtract_days( date, num ) + result = date -= num.days + if result.day > 28 + result = result.change({day: 28-num}) + result -= 1.month + end + result + end + context "when the reminder is a day of the month" do before do choose "Day of Month" @@ -88,94 +110,109 @@ end it "prior to the current date and start date" do - fill_in "#{form_prefix}_day_of_month", with: (@now - 2.days).day - fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") + reminder_date = safe_subtract_days(@now, 2) + start_date = safe_subtract_days(@now, 1) + + fill_in "#{form_prefix}_day_of_month", with: reminder_date.day + fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now - 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 2.days).day)) + schedule = IceCube::Schedule.new(start_date) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") - schedule = IceCube::Schedule.new(@now + 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 2.days).day)) + start_date = safe_add_days(@now, 1) + fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") + schedule = IceCube::Schedule.new(start_date) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) fill_in "#{form_prefix}_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 2.days).day)) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "after the current date and start date" do - fill_in "#{form_prefix}_day_of_month", with: (@now + 2.days).day - fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") + reminder_date = safe_add_days(@now, 2) + start_date = safe_subtract_days(@now, 1) + fill_in "#{form_prefix}_day_of_month", with: reminder_date.day + fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now - 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 2.days).day)) + schedule = IceCube::Schedule.new(start_date) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") - schedule = IceCube::Schedule.new(@now + 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 2.days).day)) + start_date = safe_add_days(@now, 1) + fill_in "#{form_prefix}_start_date", with: (start_date).strftime("%Y-%m-%d") + schedule = IceCube::Schedule.new(start_date) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) fill_in "#{form_prefix}_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 2.days).day)) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "after the start date and prior to the current date" do - fill_in "#{form_prefix}_start_date", with: (@now - 2.days).strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: (@now - 1.day).day + start_date = safe_subtract_days(@now, 2) + reminder_date = safe_subtract_days(@now, 1) + fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: reminder_date.day expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now - 2.days) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 1.day).day)) + schedule = IceCube::Schedule.new(start_date) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "after the current date and prior to the start date" do - fill_in "#{form_prefix}_start_date", with: (@now + 2.days).strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: (@now + 1.day).day + start_date = safe_add_days(@now, 2) + reminder_date = safe_add_days(@now, 1) + fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: reminder_date.day expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now + 2.days) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 1.day).day)) + schedule = IceCube::Schedule.new(start_date) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "same as the current date and prior to the start date" do - fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") + start_date = safe_add_days(@now, 1) + fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") fill_in "#{form_prefix}_day_of_month", with: @now.day expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now + 1.day) + schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(@now.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "same as the current date and after the start date" do - fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") + start_date = safe_subtract_days(@now, 1) + fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") fill_in "#{form_prefix}_day_of_month", with: @now.day expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now - 1.day) + schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(@now.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "same as the start date and prior to the current date" do - fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: (@now - 1.day).day + start_date = safe_subtract_days(@now, 1) + fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: start_date.day expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now - 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now - 1.day).day)) + schedule = IceCube::Schedule.new(start_date) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(start_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end it "same as the start date and after the current date" do - fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: (@now + 1.day).day + start_date = safe_add_days(@now, 1) + fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_day_of_month", with: start_date.day expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now + 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month((@now + 1.day).day)) + schedule = IceCube::Schedule.new(start_date) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(start_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end From 69e35fefb000f37da90b722bdb2acfb71d495d7c Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sun, 29 Jun 2025 07:48:33 -0700 Subject: [PATCH 63/94] Exposed IceCube occurs_on? function through ReminderScheduleService and updated FetchPartnersToRemindNowService to use it --- .../partners/fetch_partners_to_remind_now_service.rb | 12 +++++------- app/services/reminder_schedule_service.rb | 4 ++++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/services/partners/fetch_partners_to_remind_now_service.rb b/app/services/partners/fetch_partners_to_remind_now_service.rb index c6e792fd9c..ed96f1f851 100644 --- a/app/services/partners/fetch_partners_to_remind_now_service.rb +++ b/app/services/partners/fetch_partners_to_remind_now_service.rb @@ -5,26 +5,24 @@ def fetch deactivated_status = ::Partner.statuses[:deactivated] partners_with_group_reminders = ::Partner.left_joins(:partner_group) - .where.not(partner_groups: {reminder_schedule: nil}) + .where.not(partner_groups: {reminder_schedule_definition: nil}) .where.not(partner_groups: {deadline_day: nil}) .where.not(status: deactivated_status) # where partner groups have reminder schedule match filtered_partner_groups = partners_with_group_reminders.select do |partner| - sched = IceCube::Schedule.from_ical partner.partner_group.reminder_schedule - sched.occurs_on?(current_day) + partner.partner_group.reminder_schedule.occurs_on?(current_day) end partners_with_only_organization_reminders = ::Partner.left_joins(:partner_group, :organization) - .where(partner_groups: {reminder_schedule: nil}) + .where(partner_groups: {reminder_schedule_definition: nil}) .where(send_reminders: true) .where.not(organizations: {deadline_day: nil}) - .where.not(organizations: {reminder_schedule: nil}) + .where.not(organizations: {reminder_schedule_definition: nil}) .where.not(status: deactivated_status) filtered_organizations = partners_with_only_organization_reminders.select do |partner| - sched = IceCube::Schedule.from_ical partner.organization.reminder_schedule - sched.occurs_on?(current_day) + partner.organization.reminder_schedule.occurs_on?(current_day) end (filtered_partner_groups + filtered_organizations).flatten.uniq end diff --git a/app/services/reminder_schedule_service.rb b/app/services/reminder_schedule_service.rb index ded3b22dcb..48809078a9 100644 --- a/app/services/reminder_schedule_service.rb +++ b/app/services/reminder_schedule_service.rb @@ -103,6 +103,10 @@ def no_fields_filled_out? every_nth_month.nil? && by_month_or_week.nil? && day_of_month.nil? && day_of_week.nil? && every_nth_day.nil? end + def occurs_on?(date) + to_icecube_schedule&.occurs_on?(date) + end + private def every_nth_month_within_range? From 8a78204f86ecd6d1e8ff6afd72cc023fb8d8783c Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 30 Jun 2025 14:07:28 -0700 Subject: [PATCH 64/94] Fixed day_of_week and every_nth_day validations not casting before comparing --- app/services/reminder_schedule_service.rb | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/services/reminder_schedule_service.rb b/app/services/reminder_schedule_service.rb index 48809078a9..c9d7391a27 100644 --- a/app/services/reminder_schedule_service.rb +++ b/app/services/reminder_schedule_service.rb @@ -34,9 +34,9 @@ class ReminderScheduleService validates :by_month_or_week, inclusion: {in: %w[day_of_month day_of_week]} validates :day_of_month, if: -> { @by_month_or_week == "day_of_month" }, presence: true validate :day_of_month_is_within_range?, if: -> { @by_month_or_week == "day_of_month" } - validates :day_of_week, if: -> { @by_month_or_week == "day_of_week" }, inclusion: {in: %w[0 1 2 3 4 5 6]} - validates :every_nth_day, if: -> { @by_month_or_week == "day_of_week" }, inclusion: {in: %w[1 2 3 4 -1]} - + validate :day_of_week_is_within_range?, if: -> { @by_month_or_week == "day_of_week" } + validate :every_nth_day_is_within_range?, if: -> { @by_month_or_week == "day_of_week" } + def initialize(parameter_hash) @every_nth_month = parameter_hash[:every_nth_month] @start_date = parameter_hash[:start_date] @@ -129,4 +129,16 @@ def day_of_month_is_within_range? end end + def day_of_week_is_within_range? + unless [0, 1, 2, 3, 4, 5, 6,].include? day_of_week.to_i + errors.add(:day_of_week, "Day of week must be one of #{DAY_OF_WEEK_COLLECTION}") + end + end + + def every_nth_day_is_within_range? + unless [1, 2, 3, 4, -1,].include? every_nth_day.to_i + errors.add(:every_nth_day, "Every Nth day must be one of #{EVERY_NTH_COLLECTION}") + end + end + end \ No newline at end of file From d73192942f0f11d0a31d58f8ce89759a2b1827c5 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 30 Jun 2025 14:11:50 -0700 Subject: [PATCH 65/94] Reverted change to _details because it didn't show 'Not defined' for an empty schedule object --- app/views/organizations/_details.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb index ddfc8eeffb..b9e2b8343b 100644 --- a/app/views/organizations/_details.html.erb +++ b/app/views/organizations/_details.html.erb @@ -174,7 +174,7 @@
Default reminder day (day of month an email reminder to submit Requests is sent to Partners)

<%= fa_icon "calendar" %> - <%= @organization.reminder_schedule&.show_description || 'Not defined' %> + <%= @organization.reminder_schedule&.show_description.blank? ? 'Not defined' : @organization.reminder_schedule&.show_description %>

From 46d9d3a096f2006efd8bfd802ca5903f55392a11 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Tue, 1 Jul 2025 08:04:32 -0700 Subject: [PATCH 66/94] Updated shared deadline day field tests to reflect the new form --- .../deadline_day_fields_shared_example.rb | 126 +++++++++--------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 0d89b2bd27..b6191e2bcd 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -1,7 +1,7 @@ RSpec.shared_examples_for "deadline and reminder form" do |form_prefix, save_button, reload_record, post_form_submit| it "can set a reminder on a day of the month" do choose "Day of Month" - fill_in "#{form_prefix}_day_of_month", with: 1 + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: 1 fill_in "Default deadline day (final day of month to submit Requests)", with: 10 click_on save_button @@ -14,8 +14,8 @@ it "can set a reminder on a day of the week" do choose "Day of the Week" - select("First", from: "#{form_prefix}_every_nth_day") - select("Sunday", from: "#{form_prefix}_day_of_week") + select("First", from: "#{form_prefix}_reminder_schedule_service_every_nth_day") + select("Sunday", from: "#{form_prefix}_reminder_schedule_service_day_of_week") fill_in "Default deadline day (final day of month to submit Requests)", with: 10 click_on save_button @@ -29,7 +29,7 @@ it "can set a monthly frequency for reminders" do select("Every 3 month", from: "How frequently should reminders be sent (e.g. \"monthly\", \"every 3 months\", etc.)?") choose "Day of Month" - fill_in "#{form_prefix}_day_of_month", with: 1 + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: 1 fill_in "Default deadline day (final day of month to submit Requests)", with: 10 click_on save_button @@ -53,7 +53,7 @@ it "warns the user if they enter the same reminder and deadline day" do choose "Day of Month" - fill_in "#{form_prefix}_day_of_month", with: 15 + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: 15 fill_in "Default deadline day (final day of month to submit Requests)", with: 15 expect(page).to have_content("Reminder day cannot be the same as deadline day.") expect(page).to_not have_content("Your next reminder date is") @@ -62,11 +62,11 @@ it "warns the user if the reminder day is outside the range of 1 to 28" do choose "Day of Month" - fill_in "#{form_prefix}_day_of_month", with: "-1" + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: "-1" expect(page).to have_content("Reminder day must be between 1 and 28") - fill_in "#{form_prefix}_day_of_month", with: "20" + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: "20" expect(page).to_not have_content("Reminder day must be between 1 and 28") - fill_in "#{form_prefix}_day_of_month", with: "100" + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: "100" expect(page).to have_content("Reminder day must be between 1 and 28") end @@ -82,7 +82,7 @@ describe "calculates the reminder and deadline dates" do - # The reminder day (the #{form_prefix}_day_of_month field ) has to be less than or equal to 28. + # The reminder day (the #{form_prefix}_reminder_schedule_service_day_of_month field ) has to be less than or equal to 28. # These functions are implemented to calculate dates prior or after @now that do not fall on a # date with a day greater than 28. def safe_add_days( date, num ) @@ -113,20 +113,20 @@ def safe_subtract_days( date, num ) reminder_date = safe_subtract_days(@now, 2) start_date = safe_subtract_days(@now, 1) - fill_in "#{form_prefix}_day_of_month", with: reminder_date.day - fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: reminder_date.day + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) start_date = safe_add_days(@now, 1) - fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - fill_in "#{form_prefix}_start_date", with: @now.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) @@ -135,20 +135,20 @@ def safe_subtract_days( date, num ) it "after the current date and start date" do reminder_date = safe_add_days(@now, 2) start_date = safe_subtract_days(@now, 1) - fill_in "#{form_prefix}_day_of_month", with: reminder_date.day - fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: reminder_date.day + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) start_date = safe_add_days(@now, 1) - fill_in "#{form_prefix}_start_date", with: (start_date).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (start_date).strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - fill_in "#{form_prefix}_start_date", with: @now.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) @@ -157,8 +157,8 @@ def safe_subtract_days( date, num ) it "after the start date and prior to the current date" do start_date = safe_subtract_days(@now, 2) reminder_date = safe_subtract_days(@now, 1) - fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: reminder_date.day + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: reminder_date.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) @@ -168,8 +168,8 @@ def safe_subtract_days( date, num ) it "after the current date and prior to the start date" do start_date = safe_add_days(@now, 2) reminder_date = safe_add_days(@now, 1) - fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: reminder_date.day + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: reminder_date.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) @@ -178,8 +178,8 @@ def safe_subtract_days( date, num ) it "same as the current date and prior to the start date" do start_date = safe_add_days(@now, 1) - fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: @now.day + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: @now.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(@now.day)) @@ -188,8 +188,8 @@ def safe_subtract_days( date, num ) it "same as the current date and after the start date" do start_date = safe_subtract_days(@now, 1) - fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: @now.day + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: @now.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(@now.day)) @@ -198,8 +198,8 @@ def safe_subtract_days( date, num ) it "same as the start date and prior to the current date" do start_date = safe_subtract_days(@now, 1) - fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: start_date.day + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: start_date.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(start_date.day)) @@ -208,8 +208,8 @@ def safe_subtract_days( date, num ) it "same as the start date and after the current date" do start_date = safe_add_days(@now, 1) - fill_in "#{form_prefix}_start_date", with: start_date.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: start_date.day + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: start_date.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(start_date.day)) @@ -217,8 +217,8 @@ def safe_subtract_days( date, num ) end it "same as the start and current date" do - fill_in "#{form_prefix}_start_date", with: @now.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_day_of_month", with: @now.day + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: @now.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: @now.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(@now.day)) @@ -243,21 +243,21 @@ def calc_every_nth_day(target_date) it "prior to the current date and start date" do target_date = @now - 2.days every_nth_day = calc_every_nth_day(target_date) - select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") - select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") + select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now - 1.day) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now + 1.day) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - fill_in "#{form_prefix}_start_date", with: @now.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) @@ -266,21 +266,21 @@ def calc_every_nth_day(target_date) it "after the current date and start date" do target_date = @now + 2.days every_nth_day = calc_every_nth_day(target_date) - select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") - select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") + select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now - 1.day) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now + 1.day) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - fill_in "#{form_prefix}_start_date", with: @now.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) @@ -289,10 +289,10 @@ def calc_every_nth_day(target_date) it "after the start date and prior to the current date" do target_date = @now - 1.day every_nth_day = calc_every_nth_day(target_date) - select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") - select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") + select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "#{form_prefix}_start_date", with: (@now - 2.days).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now - 2.days).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now - 2.days) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) @@ -302,10 +302,10 @@ def calc_every_nth_day(target_date) it "after the current date and prior to the start date" do target_date = @now + 1.day every_nth_day = calc_every_nth_day(target_date) - select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") - select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") + select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "#{form_prefix}_start_date", with: (@now + 2.days).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now + 2.days).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now + 2.days) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) @@ -315,10 +315,10 @@ def calc_every_nth_day(target_date) it "same as the current date and prior to the start date" do target_date = @now every_nth_day = calc_every_nth_day(target_date) - select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") - select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") + select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "#{form_prefix}_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now + 1.day) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) @@ -328,10 +328,10 @@ def calc_every_nth_day(target_date) it "same as the current date and after the start date" do target_date = @now every_nth_day = calc_every_nth_day(target_date) - select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") - select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") + select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "#{form_prefix}_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now - 1.day) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) @@ -341,10 +341,10 @@ def calc_every_nth_day(target_date) it "same as the start date and prior to the current date" do target_date = @now - 1.day every_nth_day = calc_every_nth_day(target_date) - select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") - select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") + select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "#{form_prefix}_start_date", with: target_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: target_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(target_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) @@ -354,10 +354,10 @@ def calc_every_nth_day(target_date) it "same as the start date and after the current date" do target_date = @now + 1.day every_nth_day = calc_every_nth_day(target_date) - select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") - select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") + select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "#{form_prefix}_start_date", with: target_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: target_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(target_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) @@ -367,10 +367,10 @@ def calc_every_nth_day(target_date) it "same as the start and current date" do target_date = @now every_nth_day = calc_every_nth_day(target_date) - select(Deadlinable::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_every_nth_day") - select(Deadlinable::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_day_of_week") + select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") + select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "#{form_prefix}_start_date", with: target_date.strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: target_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(target_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) @@ -382,7 +382,7 @@ def calc_every_nth_day(target_date) it "the deadline day form's reminder and deadline dates are consistent with the dates calculated by the FetchPartnersToRemindNowService and DeadlineService" do choose "Day of Month" select("Every 2 month", from: "How frequently should reminders be sent (e.g. \"monthly\", \"every 3 months\", etc.)?") - fill_in "#{form_prefix}_day_of_month", with: 14 + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: 14 fill_in "Default deadline day (final day of month to submit Requests)", with: 21 reminder_text = find('small[data-deadline-day-target="reminderText"]').text From 5cff85db1317663c1fc2ad7835224ee04d1c39c6 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Tue, 1 Jul 2025 08:05:15 -0700 Subject: [PATCH 67/94] Forgot to update partner group form to use new deadline_day_fields partial --- app/views/partner_groups/_form.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/partner_groups/_form.html.erb b/app/views/partner_groups/_form.html.erb index 042888cda2..57ec911265 100644 --- a/app/views/partner_groups/_form.html.erb +++ b/app/views/partner_groups/_form.html.erb @@ -30,7 +30,7 @@

You must fill out the details below to send reminders.

- <%= render 'shared/deadline_day_fields', f: f %> + <%= render 'shared/deadline_day_fields', parent_form: f, parent_object: @partner_group %>
From 0d7941245075adc7e7c05b6410e2bd538d36bb87 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Tue, 1 Jul 2025 11:13:53 -0700 Subject: [PATCH 68/94] Moved test comparing output of FetchPartnersToRemindNowService to what is shown in the form out of the shared examples, as orgs created via the admin interface won't have partners --- .../deadline_day_fields_shared_example.rb | 35 +++---------------- spec/system/organization_system_spec.rb | 33 ++++++++++++++--- spec/system/partner_system_spec.rb | 31 +++++++++++++--- 3 files changed, 59 insertions(+), 40 deletions(-) diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index b6191e2bcd..d85cc3922b 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -1,4 +1,4 @@ -RSpec.shared_examples_for "deadline and reminder form" do |form_prefix, save_button, reload_record, post_form_submit| +RSpec.shared_examples_for "deadline and reminder form" do |form_prefix, save_button, post_form_submit| it "can set a reminder on a day of the month" do choose "Day of Month" fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: 1 @@ -88,7 +88,7 @@ def safe_add_days( date, num ) result = date += num.days if result.day > 28 - result = result.change({day: num}) + result = result.change({day: 1+num}) result += 1.month end result @@ -106,7 +106,7 @@ def safe_subtract_days( date, num ) context "when the reminder is a day of the month" do before do choose "Day of Month" - @now = Time.zone.now + @now = safe_add_days(Time.zone.now, 0) end it "prior to the current date and start date" do @@ -229,7 +229,7 @@ def safe_subtract_days( date, num ) context "when the reminder is a day of the week" do before do choose "Day of the Week" - @now = Time.zone.now + @now = safe_add_days(Time.zone.now, 0) end def calc_every_nth_day(target_date) @@ -378,31 +378,4 @@ def calc_every_nth_day(target_date) end end end - - it "the deadline day form's reminder and deadline dates are consistent with the dates calculated by the FetchPartnersToRemindNowService and DeadlineService" do - choose "Day of Month" - select("Every 2 month", from: "How frequently should reminders be sent (e.g. \"monthly\", \"every 3 months\", etc.)?") - fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: 14 - fill_in "Default deadline day (final day of month to submit Requests)", with: 21 - - reminder_text = find('small[data-deadline-day-target="reminderText"]').text - reminder_text.slice!("Your next reminder date is ") - reminder_text.slice!(".") - shown_recurrence_date = Time.zone.strptime(reminder_text, "%a %b %d %Y") - - deadline_text = find('small[data-deadline-day-target="deadlineText"]').text - deadline_text.slice!("Your next deadline date is ") - deadline_text.slice!(".") - shown_deadline_date = Time.zone.strptime(deadline_text, "%a %b %d %Y") - - click_on save_button - send(reload_record) - - expect(Partners::FetchPartnersToRemindNowService.new.fetch).to_not include(partner) - - travel_to shown_recurrence_date - - expect(Partners::FetchPartnersToRemindNowService.new.fetch).to include(partner) - expect(DeadlineService.new(partner: partner).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date - end end diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index 2d3dec9988..7741132c54 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -86,15 +86,38 @@ expect(page.find(".alert")).to have_content "Updated" end - def reload_record - organization.reload - end - def post_form_submit expect(page.find(".alert")).to have_content "Updated your organization!" end - it_behaves_like "deadline and reminder form", "organization", "Save", :reload_record, :post_form_submit + it_behaves_like "deadline and reminder form", "organization", "Save", :post_form_submit + + it "the deadline day form's reminder and deadline dates are consistent with the dates calculated by the FetchPartnersToRemindNowService and DeadlineService" do + choose "Day of Month" + select("Every 2 month", from: "How frequently should reminders be sent (e.g. \"monthly\", \"every 3 months\", etc.)?") + fill_in "organization_reminder_schedule_service_day_of_month", with: 14 + fill_in "Default deadline day (final day of month to submit Requests)", with: 21 + + reminder_text = find('small[data-deadline-day-target="reminderText"]').text + reminder_text.slice!("Your next reminder date is ") + reminder_text.slice!(".") + shown_recurrence_date = Time.zone.strptime(reminder_text, "%a %b %d %Y") + + deadline_text = find('small[data-deadline-day-target="deadlineText"]').text + deadline_text.slice!("Your next deadline date is ") + deadline_text.slice!(".") + shown_deadline_date = Time.zone.strptime(deadline_text, "%a %b %d %Y") + + click_on "Save" + organization.reload + + expect(Partners::FetchPartnersToRemindNowService.new.fetch).to_not include(partner) + + travel_to shown_recurrence_date + + expect(Partners::FetchPartnersToRemindNowService.new.fetch).to include(partner) + expect(DeadlineService.new(partner: partner).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date + end it 'can select if the org repackages essentials' do choose('organization[repackage_essentials]', option: true) diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 032036cd28..86ff064d50 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -631,7 +631,7 @@ check 'Yes' choose 'Day of Month' - fill_in "partner_group_day_of_month", with: 1 + fill_in "partner_group_reminder_schedule_service_day_of_month", with: 1 fill_in "partner_group_deadline_day", with: 25 find_button('Add Partner Group').click @@ -682,11 +682,34 @@ check 'Yes' end - def reload_record + it_behaves_like "deadline and reminder form", "partner_group", "Update Partner Group" + + it "the deadline day form's reminder and deadline dates are consistent with the dates calculated by the FetchPartnersToRemindNowService and DeadlineService" do + choose "Day of Month" + select("Every 2 month", from: "How frequently should reminders be sent (e.g. \"monthly\", \"every 3 months\", etc.)?") + fill_in "partner_group_reminder_schedule_service_day_of_month", with: 14 + fill_in "Default deadline day (final day of month to submit Requests)", with: 21 + + reminder_text = find('small[data-deadline-day-target="reminderText"]').text + reminder_text.slice!("Your next reminder date is ") + reminder_text.slice!(".") + shown_recurrence_date = Time.zone.strptime(reminder_text, "%a %b %d %Y") + + deadline_text = find('small[data-deadline-day-target="deadlineText"]').text + deadline_text.slice!("Your next deadline date is ") + deadline_text.slice!(".") + shown_deadline_date = Time.zone.strptime(deadline_text, "%a %b %d %Y") + + click_on "Update Partner Group" existing_partner_group.reload - end - it_behaves_like "deadline and reminder form", "partner_group", "Update Partner Group", :reload_record + expect(Partners::FetchPartnersToRemindNowService.new.fetch).to_not include(partner) + + travel_to shown_recurrence_date + + expect(Partners::FetchPartnersToRemindNowService.new.fetch).to include(partner) + expect(DeadlineService.new(partner: partner).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date + end end end end From 2c5fdecc2419f0615995bdaf3599bf643680f107 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Tue, 1 Jul 2025 11:21:24 -0700 Subject: [PATCH 69/94] Removed outdated test on removed edit_admin_organization_path, moved tests on deadline day form to happen on new_admin_organization_path --- .../system/admin/organizations_system_spec.rb | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/spec/system/admin/organizations_system_spec.rb b/spec/system/admin/organizations_system_spec.rb index 005f425acb..e6797eb84e 100644 --- a/spec/system/admin/organizations_system_spec.rb +++ b/spec/system/admin/organizations_system_spec.rb @@ -39,27 +39,6 @@ expect(page).not_to have_content("Next ›") expect(page).not_to have_content("Last »") end - - describe "can edit organization details" do - let(:partner) { create(:partner, organization: first_org) } - - before do - visit edit_admin_organization_path({id: first_org.id}) - end - - def reload_record - first_org.reload - end - - def post_form_submit - expect(page.find(".alert")).to have_content "Updated organization!" - within(find("tr", text: first_org.name.to_s)) do - first(:link, "View").click - end - end - - it_behaves_like "deadline and reminder form", "organization", "Save", :reload_record, :post_form_submit - end end context "while logged in as a super admin and there are enough organizations to trigger pagination" do @@ -138,7 +117,7 @@ def post_form_submit fill_in "organization_user_email", with: admin_user_params[:email] choose 'Day of Month' - fill_in "organization_day_of_month", with: 1 + fill_in "organization_reminder_schedule_service_day_of_month", with: 1 click_on "Save" end @@ -175,5 +154,23 @@ def post_form_submit expect(page).to have_content("Users") expect(page).to have_content("Receive email when Partner makes a Request?") end + + describe "can create an organization with deadline and reminder" do + before do + visit new_admin_organization_path + within "form#new_organization" do + fill_in "organization_name", with: "aaa" # So the new org will be on the first page + end + end + + def post_form_submit + expect(page.find(".alert")).to have_content "Organization added!" + within(find("td", text: "aaa").sibling(".text-right")) do + first(:link, "View").click + end + end + + it_behaves_like "deadline and reminder form", "organization", "Save", :post_form_submit + end end end From da9cc5933e9bc1c34f3848f452b495169e0a083c Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 3 Jul 2025 09:26:30 -0700 Subject: [PATCH 70/94] Made reminder_schedule_service params permitted but optional --- app/controllers/admin/organizations_controller.rb | 2 +- app/controllers/organizations_controller.rb | 2 +- app/controllers/partner_groups_controller.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb index 20c4c31ba9..607cc75c15 100644 --- a/app/controllers/admin/organizations_controller.rb +++ b/app/controllers/admin/organizations_controller.rb @@ -77,7 +77,7 @@ def organization_params end def reminder_schedule_params - params.require(:organization).require(:reminder_schedule_service).permit([*ReminderScheduleService::REMINDER_SCHEDULE_FIELDS]) + params.require(:organization).fetch(:reminder_schedule_service, {}).permit([*ReminderScheduleService::REMINDER_SCHEDULE_FIELDS]) end def user_params diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 0a02d9745d..604ad9ce57 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -109,7 +109,7 @@ def organization_params end def reminder_schedule_params - params.require(:organization).require(:reminder_schedule_service).permit([*ReminderScheduleService::REMINDER_SCHEDULE_FIELDS]) + params.require(:organization).fetch(:reminder_schedule_service, {}).permit([*ReminderScheduleService::REMINDER_SCHEDULE_FIELDS]) end def request_type_formatter(params) diff --git a/app/controllers/partner_groups_controller.rb b/app/controllers/partner_groups_controller.rb index 29a430c13e..6ab74ab9b5 100644 --- a/app/controllers/partner_groups_controller.rb +++ b/app/controllers/partner_groups_controller.rb @@ -60,7 +60,7 @@ def partner_group_params end def reminder_schedule_params - params.require(:partner_group).require(:reminder_schedule_service).permit([*ReminderScheduleService::REMINDER_SCHEDULE_FIELDS]) + params.require(:partner_group).fetch(:reminder_schedule_service, {}).permit([*ReminderScheduleService::REMINDER_SCHEDULE_FIELDS]) end def set_items_categories From f1fc5f95e0fd9575e57d7c2e2f0c863d59b61983 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 3 Jul 2025 09:31:35 -0700 Subject: [PATCH 71/94] Removed reminder schedule definitions from org and partner group factories as they aren't necessary to initializing those models --- spec/factories/organizations.rb | 11 ----------- spec/factories/partner_groups.rb | 12 ------------ spec/services/deadline_service_spec.rb | 4 ++-- 3 files changed, 2 insertions(+), 25 deletions(-) diff --git a/spec/factories/organizations.rb b/spec/factories/organizations.rb index 1a346800bf..1f44c8f744 100644 --- a/spec/factories/organizations.rb +++ b/spec/factories/organizations.rb @@ -44,11 +44,6 @@ skip_items { false } end - reminder_schedule_definition = ReminderScheduleService.new({ - every_nth_month: "1", - by_month_or_week: "day_of_month", - day_of_month: 10 - }) sequence(:name) { |n| "Essentials Bank #{n}" } # 037000863427 sequence(:email) { |n| "email#{n}@example.com" } # 037000863427 sequence(:url) { |n| "https://organization#{n}.org" } # 037000863427 @@ -56,15 +51,9 @@ city { 'Front Royal' } state { 'VA' } zipcode { '22630' } - reminder_schedule_definition { reminder_schedule_definition.to_ical } logo { Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/logo.jpg"), "image/jpeg") } - trait :without_deadlines do - reminder_schedule_definition { nil } - deadline_day { nil } - end - trait :with_items do after(:create) do |instance, evaluator| Seeds.seed_base_items if BaseItem.count.zero? # seeds 45 base items if none exist diff --git a/spec/factories/partner_groups.rb b/spec/factories/partner_groups.rb index 685dc94308..5658240b04 100644 --- a/spec/factories/partner_groups.rb +++ b/spec/factories/partner_groups.rb @@ -14,20 +14,8 @@ # FactoryBot.define do - reminder_schedule_definition = ReminderScheduleService.new({ - every_nth_month: "1", - by_month_or_week: "day_of_month", - day_of_month: 10 - }) - factory :partner_group do sequence(:name) { |n| "Group #{n}" } organization { Organization.try(:first) || create(:organization) } - reminder_schedule_definition { reminder_schedule_definition.to_ical } - - trait :without_deadlines do - reminder_schedule_definition { nil } - deadline_day { nil } - end end end diff --git a/spec/services/deadline_service_spec.rb b/spec/services/deadline_service_spec.rb index bb1ed14ab8..f584cbe367 100644 --- a/spec/services/deadline_service_spec.rb +++ b/spec/services/deadline_service_spec.rb @@ -1,6 +1,6 @@ RSpec.describe DeadlineService, type: :service do - let(:organization) { build_stubbed(:organization, :without_deadlines) } - let(:partner_group) { build_stubbed(:partner_group, :without_deadlines, organization: organization) } + let(:organization) { build_stubbed(:organization) } + let(:partner_group) { build_stubbed(:partner_group, organization: organization) } let(:partner) { build_stubbed(:partner, organization: organization, partner_group: partner_group) } let(:today) { Date.new(2022, 1, 10) } From 50256c1cc0f800d6d11b25491fce6b296536342d Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 3 Jul 2025 09:35:03 -0700 Subject: [PATCH 72/94] Updated tests to use new reminder schedule service --- ...tch_partners_to_remind_now_service_spec.rb | 84 +++++++++++-------- 1 file changed, 49 insertions(+), 35 deletions(-) diff --git a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb index 20ac9827e6..0ad4586a9e 100644 --- a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb +++ b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb @@ -9,33 +9,33 @@ context "that has an organization with a global reminder & deadline" do context "that is for today" do before do - partner.organization.update( + partner.organization.update(deadline_day:current_day + 2) + partner.organization.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_month", - day_of_month: current_day.to_s, - every_nth_month: "1", - deadline_day: (current_day + 2).to_s - ) + day_of_month: current_day, + every_nth_month: 1 + }) + partner.organization.save end it "should include that partner" do - schedule = IceCube::Schedule.from_ical partner.organization.reminder_schedule - expect(schedule.occurs_on?(Time.current)).to be_truthy + expect(partner.organization.reminder_schedule.occurs_on?(Time.current)).to be_truthy expect(subject).to include(partner) end context "as matched by day of the week" do before do - partner.organization.update( + partner.organization.update(deadline_day:current_day + 2) + partner.organization.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_week", - day_of_week: "2", - every_nth_day: "2", - every_nth_month: "1", - deadline_day: (current_day + 2).to_s - ) + day_of_week: 2, + every_nth_day: 2, + every_nth_month: 1 + }) + partner.organization.save end it "should include that partner" do - schedule = IceCube::Schedule.from_ical partner.organization.reminder_schedule - expect(schedule.occurs_on?(Time.current)).to be_truthy + expect(partner.organization.reminder_schedule.occurs_on?(Time.current)).to be_truthy expect(subject).to include(partner) end end @@ -63,8 +63,12 @@ context "that is not for today" do before do - partner.organization.update(by_month_or_week: "day_of_month", - day_of_month: current_day - 1, deadline_day: current_day + 2) + partner.organization.update(deadline_day: current_day + 2) + partner.organization.reminder_schedule.assign_attributes({ + by_month_or_week: "day_of_month", + day_of_month: current_day - 1 + }) + partner.organization.save end it "should NOT include that partner" do @@ -76,19 +80,23 @@ before do partner_group = create( :partner_group, - by_month_or_week: "day_of_month", - day_of_month: current_day.to_s, - every_nth_month: "1", - deadline_day: (current_day + 2).to_s + deadline_day: current_day + 2 ) + partner_group.reminder_schedule.assign_attributes({ + by_month_or_week: "day_of_month", + day_of_month: current_day, + every_nth_month: 1 + }) + partner_group.save partner_group.partners << partner - partner.organization.update( + partner.organization.update(deadline_day:current_day + 2) + partner.organization.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_month", - day_of_month: (current_day - 1).to_s, - every_nth_month: "1", - deadline_day: (current_day + 2).to_s - ) + day_of_month: current_day - 1, + every_nth_month: 1 + }) + partner.organization.save end it "should remind based on the partner group instead of the organization level reminder" do @@ -109,7 +117,7 @@ context "that does NOT have a organization with a global reminder & deadline" do before do - partner.organization.update(reminder_schedule: nil, deadline_day: nil) + partner.organization.update(reminder_schedule_definition: nil, deadline_day: nil) end context "and is a part of a partner group that does have them defined" do @@ -117,11 +125,14 @@ before do partner_group = create( :partner_group, - by_month_or_week: "day_of_month", - day_of_month: current_day.to_s, - every_nth_month: "1", - deadline_day: (current_day + 2).to_s + deadline_day: current_day + 2 ) + partner_group.reminder_schedule.assign_attributes({ + by_month_or_week: "day_of_month", + day_of_month: current_day, + every_nth_month: 1, + }) + partner_group.save partner_group.partners << partner end @@ -154,11 +165,14 @@ before do partner_group = create( :partner_group, - by_month_or_week: "day_of_month", - day_of_month: (current_day - 1).to_s, - every_nth_month: "1", - deadline_day: (current_day + 2).to_s + deadline_day: current_day + 2 ) + partner_group.reminder_schedule.assign_attributes({ + by_month_or_week: "day_of_month", + day_of_month: current_day - 1, + every_nth_month: 1, + }) + partner_group.save partner_group.partners << partner end From d41b85824a6ba533d7938e0b757eb97d7737c85c Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 3 Jul 2025 09:47:27 -0700 Subject: [PATCH 73/94] Updated reminder_schedule_is_empty_or_valid? to not raise errors if the user hasn't filled out the form at all, added a check to partner groups to make sure a valid reminder schedule is present when send_reminders is true --- app/models/organization.rb | 7 ++++++- app/models/partner_group.rb | 14 +++++++++++++- spec/models/partner_group_spec.rb | 16 ++++++++++++++-- .../deadline_day_fields_shared_example.rb | 12 +----------- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/app/models/organization.rb b/app/models/organization.rb index 689ee68c14..4d77a46a72 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -260,7 +260,10 @@ def save_reminder_schedule_definition end def reminder_schedule_is_empty_or_valid? - unless reminder_schedule.no_fields_filled_out? || (reminder_schedule.valid? && deadline_not_on_reminder_date?) + # The schedule shouldn't be validated if the user hasn't touched that form, + # so if by_month_or_week is still the default (nil) assume the user didn't + # intend to fill out that form and don't validate. + unless reminder_schedule.no_fields_filled_out? || reminder_schedule.by_month_or_week.nil? || (reminder_schedule.valid? && deadline_not_on_reminder_date?) errors.merge!(reminder_schedule.errors) end end @@ -268,7 +271,9 @@ def reminder_schedule_is_empty_or_valid? def deadline_not_on_reminder_date? if reminder_schedule.day_of_month.to_i == deadline_day.to_i errors.add(:day_of_month, "Reminder day must not be the same as deadline day") + false end + true end private diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index 0a45bf12ae..158324b1f3 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -28,6 +28,7 @@ class PartnerGroup < ApplicationRecord allow_nil: true } validate :reminder_schedule_is_empty_or_valid? + validate :reminder_schedule_present?, if: :send_reminders? before_save :save_reminder_schedule_definition @@ -44,14 +45,25 @@ def save_reminder_schedule_definition end def reminder_schedule_is_empty_or_valid? - unless reminder_schedule.no_fields_filled_out? || (reminder_schedule.valid? && deadline_not_on_reminder_date?) + # The schedule shouldn't be validated if the user hasn't touched that form, + # so if by_month_or_week is still the default (nil) assume the user didn't + # intend to fill out that form and don't validate. + unless reminder_schedule.no_fields_filled_out? || reminder_schedule.by_month_or_week.nil? || (reminder_schedule.valid? && deadline_not_on_reminder_date?) errors.merge!(reminder_schedule.errors) end end + def reminder_schedule_present? + unless reminder_schedule.valid? && deadline_not_on_reminder_date? + errors.add(:send_reminders, "Valid reminder schedule must be present if send_reminders is true") + end + end + def deadline_not_on_reminder_date? if reminder_schedule.day_of_month.to_i == deadline_day.to_i errors.add(:day_of_month, "Reminder day must not be the same as deadline day") + false end + true end end diff --git a/spec/models/partner_group_spec.rb b/spec/models/partner_group_spec.rb index 301d332a3f..793244b14f 100644 --- a/spec/models/partner_group_spec.rb +++ b/spec/models/partner_group_spec.rb @@ -52,10 +52,22 @@ end describe "deadline_day && reminder_schedule must be defined if send_reminders=true" do - let(:partner_group) { build(:partner_group, send_reminders: true, deadline_day: nil, reminder_schedule: nil) } + let(:valid_reminder_schedule) { + ReminderScheduleService.new({ + by_month_or_week: "day_of_month", + every_nth_month: 1, + day_of_month: 9 + }).to_ical + } it "should not be valid" do - expect(partner_group).not_to be_valid + expect(build(:partner_group, send_reminders: true)).not_to be_valid + expect(build(:partner_group, send_reminders: true, deadline_day: 10)).not_to be_valid + expect(build(:partner_group, send_reminders: true, reminder_schedule_definition: valid_reminder_schedule)).not_to be_valid + end + + it "should be valid" do + expect(build(:partner_group, send_reminders: true, deadline_day: 10, reminder_schedule_definition: valid_reminder_schedule)).to be_valid end end end diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index d85cc3922b..4821cf5ba2 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -38,17 +38,7 @@ end expect(page).to have_content("Every 3 months on the 1st day of the month") - end - - it "can set a default deadline day" do - fill_in "Default deadline day (final day of month to submit Requests)", with: 20 - click_on save_button - - if post_form_submit - send(post_form_submit) - end - - expect(page).to have_content("20th after the reminder") + expect(page).to have_content("10th after the reminder") end it "warns the user if they enter the same reminder and deadline day" do From fd24d5692285eeab75897e747df4c4559ab20a75 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sun, 6 Jul 2025 08:47:29 -0700 Subject: [PATCH 74/94] Teaked custom validation functions to explicitly check for presence of field --- app/services/reminder_schedule_service.rb | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/app/services/reminder_schedule_service.rb b/app/services/reminder_schedule_service.rb index c9d7391a27..f140ca8c0c 100644 --- a/app/services/reminder_schedule_service.rb +++ b/app/services/reminder_schedule_service.rb @@ -29,8 +29,7 @@ class ReminderScheduleService include ActiveModel::Validations validate :every_nth_month_within_range? - validates :start_date, presence: true - validate :start_date_is_valid_date_string? + validate :start_date_is_valid_date_or_date_string? validates :by_month_or_week, inclusion: {in: %w[day_of_month day_of_week]} validates :day_of_month, if: -> { @by_month_or_week == "day_of_month" }, presence: true validate :day_of_month_is_within_range?, if: -> { @by_month_or_week == "day_of_month" } @@ -41,7 +40,7 @@ def initialize(parameter_hash) @every_nth_month = parameter_hash[:every_nth_month] @start_date = parameter_hash[:start_date] if !@start_date - @start_date = Time.zone.now.to_date.to_s + @start_date = Time.zone.now end @by_month_or_week = parameter_hash[:by_month_or_week] @day_of_month = parameter_hash[:day_of_month] @@ -115,8 +114,8 @@ def every_nth_month_within_range? end end - def start_date_is_valid_date_string? - unless start_date.respond_to?(:strftime) || Time.zone.parse(start_date) + def start_date_is_valid_date_or_date_string? + unless start_date.present? && (start_date.respond_to?(:strftime) || Time.zone.parse(start_date)) errors.add(:start_date, "Start date must be a valid date string") end end @@ -124,19 +123,19 @@ def start_date_is_valid_date_string? def day_of_month_is_within_range? # IceCube converts negative or zero days to valid days (e.g. -1 becomes the last day of the month, 0 becomes 1) # The minimum check should no longer be necessary, but keeping it in case IceCube changes - if day_of_month.to_i < MIN_DAY_OF_MONTH || day_of_month.to_i > MAX_DAY_OF_MONTH + if start_date.present? && (day_of_month.to_i < MIN_DAY_OF_MONTH || day_of_month.to_i > MAX_DAY_OF_MONTH) errors.add(:day_of_month, "Reminder day must be between #{MIN_DAY_OF_MONTH} and #{MAX_DAY_OF_MONTH}") end end def day_of_week_is_within_range? - unless [0, 1, 2, 3, 4, 5, 6,].include? day_of_week.to_i + unless day_of_week.present? && ([0, 1, 2, 3, 4, 5, 6,].include? day_of_week.to_i) errors.add(:day_of_week, "Day of week must be one of #{DAY_OF_WEEK_COLLECTION}") end end def every_nth_day_is_within_range? - unless [1, 2, 3, 4, -1,].include? every_nth_day.to_i + unless every_nth_day.present? && ([1, 2, 3, 4, -1,].include? every_nth_day.to_i) errors.add(:every_nth_day, "Every Nth day must be one of #{EVERY_NTH_COLLECTION}") end end From 5166c4911c236bb81313f6f1a1a52aa5d96afd79 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sun, 6 Jul 2025 08:47:58 -0700 Subject: [PATCH 75/94] Added tests for the ReminderScheduleService --- .../reminder_schedule_service_spec.rb | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 spec/services/reminder_schedule_service_spec.rb diff --git a/spec/services/reminder_schedule_service_spec.rb b/spec/services/reminder_schedule_service_spec.rb new file mode 100644 index 0000000000..f91673684c --- /dev/null +++ b/spec/services/reminder_schedule_service_spec.rb @@ -0,0 +1,287 @@ +RSpec.describe ReminderScheduleService, type: :service do + + let(:day_of_month_schedule) { ReminderScheduleService.new({ + every_nth_month: 1, + by_month_or_week: "day_of_month", + day_of_month: 10 + }) } + let(:day_of_week_schedule) { ReminderScheduleService.new({ + every_nth_month: 1, + by_month_or_week: "day_of_week", + day_of_week: 0, + every_nth_day: 1 + }) } + let(:empty_schedule) { ReminderScheduleService.new({}) } + + describe "initialize" do + let(:subject) { ReminderScheduleService.new({ + every_nth_month: 1, + by_month_or_week: "day_of_month", + day_of_month: 10 + }) } + + it "returns a ReminderScheduleService instance" do + expect(subject).to be_a_kind_of(ReminderScheduleService) + expect(subject.every_nth_month).to eq 1 + expect(subject.day_of_month).to eq 10 + end + + it "assigns a default start_date if none is provided" do + expect(subject.start_date).not_to be_nil + expect(subject.start_date).to be_within(1.second).of Time.zone.now + end + end + + describe "from_ical" do + let(:subject) { ReminderScheduleService.from_ical( + "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" + ) } + + it "returns a ReminderScheduleService instance" do + expect(subject).to be_a_kind_of(ReminderScheduleService) + expect(subject.every_nth_month).to eq 1 + expect(subject.day_of_month).to eq 10 + expect(subject.start_date).to be_within(1.second).of Time.zone.local(2020,10,10) + end + + it "returns nil if a blank or invalid ical string is provided", :aggregate_failures do + expect(ReminderScheduleService.from_ical(nil)).to be_nil + expect(ReminderScheduleService.from_ical("")).to be_nil + expect(ReminderScheduleService.from_ical("notanicalstring")).to be_nil + end + + it "returns nil if the provided ical string defines no schedule rules" do + expect(ReminderScheduleService.from_ical("DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\n")).to be_nil + end + end + + describe "assign_attributes" do + it "updates the ReminderScheduleService's attributes", :aggregate_failures do + empty_schedule.assign_attributes({ + every_nth_month: 1, + by_month_or_week: "day_of_week", + day_of_week: 0, + every_nth_day: 1 + }) + + expect(empty_schedule.every_nth_month).to eq 1 + expect(empty_schedule.by_month_or_week).to eq "day_of_week" + expect(empty_schedule.day_of_week).to eq 0 + expect(empty_schedule.every_nth_day).to eq 1 + end + end + + describe "to_icecube_schedule" do + it "returns an IceCube::Schedule instance" do + result = day_of_month_schedule.to_icecube_schedule + expect(result).to be_a_kind_of(IceCube::Schedule) + end + + it "returns nil if the ReminderScheduleService isn't valid" do + expect(ReminderScheduleService.new({}).to_icecube_schedule).to eq nil + end + end + + describe "to_ical" do + it "returns ical string representation of a day_of_month schedule" do + travel_to Time.zone.local(2020, 10, 10) + expect(day_of_month_schedule.to_ical).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" + end + + it "returns ical string representation of a day_of_week schedule" do + travel_to Time.zone.local(2020, 10, 10) + expect(day_of_week_schedule.to_ical).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYDAY=1SU" + end + + it "returns nil if the ReminderScheduleService isn't valid" do + expect(empty_schedule.to_icecube_schedule).to eq nil + end + end + + describe "show_description" do + it "returns textual description of a day_of_month schedule" do + expect(day_of_month_schedule.show_description).to eq "Monthly on the 10th day of the month" + end + + it "returns textual description of a day_of_week schedule" do + expect(day_of_week_schedule.show_description).to eq "Monthly on the 1st Sunday" + end + + it "returns nil if the ReminderScheduleService isn't valid" do + expect(empty_schedule.to_icecube_schedule).to eq nil + end + end + + describe "no_fields_filled_out?" do + it "returns true if all fields, except start_date, are nil" do + expect(empty_schedule.no_fields_filled_out?).to be true + end + + it "returns false otherwise", :aggregate_failures do + empty_schedule.every_nth_month = 1 + expect(empty_schedule.no_fields_filled_out?).to be false + expect(day_of_month_schedule.no_fields_filled_out?).to be false + expect(day_of_week_schedule.no_fields_filled_out?).to be false + end + end + + describe "occurs_on?" do + before do + travel_to Time.zone.local(2020, 1, 10) + end + + context "with a day_of_month schedule" do + let(:subject) {day_of_month_schedule} + + it "returns true if the schedule occurs on the provided date", :aggregate_failures do + expect(subject.occurs_on?(Time.zone.local(2020, 10, 10))).to be true + expect(subject.occurs_on?(Time.zone.local(2020, 11, 10))).to be true + expect(subject.occurs_on?(Time.zone.local(2020, 12, 10))).to be true + end + + it "returns false otherwise", :aggregate_failures do + expect(subject.occurs_on?(Time.zone.local(2020, 10, 1))).to be false + expect(subject.occurs_on?(Time.zone.local(2020, 11, 9))).to be false + expect(subject.occurs_on?(Time.zone.local(2020, 12, 11))).to be false + end + end + + context "with a day_of_week schedule" do + let(:subject) {day_of_week_schedule} + + it "returns true if the schedule occurs on the provided date", :aggregate_failures do + expect(subject.occurs_on?(Time.zone.local(2020, 2, 2))).to be true + expect(subject.occurs_on?(Time.zone.local(2020, 3, 1))).to be true + expect(subject.occurs_on?(Time.zone.local(2020, 4, 5))).to be true + end + + it "returns false otherwise", :aggregate_failures do + expect(subject.occurs_on?(Time.zone.local(2020, 2, 3))).to be false + expect(subject.occurs_on?(Time.zone.local(2020, 3, 2))).to be false + expect(subject.occurs_on?(Time.zone.local(2020, 4, 4))).to be false + end + end + + it "returns nil if the ReminderScheduleService isn't valid" do + expect(empty_schedule.to_icecube_schedule).to eq nil + end + end + + describe "validations" do + it "validates every_nth_month falls within range", :aggregate_failures do + (1..12).step(1) do |n| + day_of_month_schedule.every_nth_month = n + expect(day_of_month_schedule).to be_valid + end + day_of_month_schedule.every_nth_month = "1" + expect(day_of_month_schedule).to be_valid + day_of_month_schedule.every_nth_month = -1 + expect(day_of_month_schedule).not_to be_valid + day_of_month_schedule.every_nth_month = "other_string" + expect(day_of_month_schedule).not_to be_valid + day_of_month_schedule.every_nth_month = nil + expect(day_of_month_schedule).not_to be_valid + end + + it "validates start_date is a valid date or date string", :aggregate_failures do + day_of_month_schedule.start_date = Time.zone.now + expect(day_of_month_schedule).to be_valid + day_of_month_schedule.start_date = "2020/10/10" + expect(day_of_month_schedule).to be_valid + day_of_month_schedule.start_date = nil + expect(day_of_month_schedule).not_to be_valid + end + + it "validates by_month_or_week is one of the accepted strings", :aggregate_failures do + day_of_month_schedule.by_month_or_week = "other_string" + expect(day_of_month_schedule).not_to be_valid + day_of_month_schedule.by_month_or_week = "day_of_month" + expect(day_of_month_schedule).to be_valid + day_of_month_schedule.by_month_or_week = nil + expect(day_of_month_schedule).not_to be_valid + + day_of_week_schedule.by_month_or_week = "other_string" + expect(day_of_week_schedule).not_to be_valid + day_of_week_schedule.by_month_or_week = "day_of_week" + expect(day_of_week_schedule).to be_valid + day_of_week_schedule.by_month_or_week = nil + expect(day_of_week_schedule).not_to be_valid + end + + context "on a day_of_month schedule" do + let(:subject) {day_of_month_schedule} + + it "validates that day_of_month falls within range" do + (1..28).step(1) do |n| + subject.day_of_month = n + expect(subject).to be_valid + end + subject.day_of_month = -1 + expect(subject).not_to be_valid + subject.day_of_month = 29 + expect(subject).not_to be_valid + subject.day_of_month = nil + expect(subject).not_to be_valid + end + + it "skips validating day_of_week_is_within_range" do + subject.day_of_week = -1 + expect(subject).to be_valid + subject.day_of_week = 7 + expect(subject).to be_valid + subject.day_of_week = nil + expect(subject).to be_valid + end + + it "skips validating every_nth_day_is_within_range" do + subject.every_nth_day = 0 + expect(subject).to be_valid + subject.every_nth_day = 5 + expect(subject).to be_valid + subject.every_nth_day = nil + expect(subject).to be_valid + end + end + + context "on a day_of_week schedule" do + let(:subject) {day_of_week_schedule} + + it "validates day_of_week falls within range" do + (0..6).step(1) do |n| + subject.day_of_week = n + expect(subject).to be_valid + end + subject.day_of_week = -1 + expect(subject).not_to be_valid + subject.day_of_week = 7 + expect(subject).not_to be_valid + subject.day_of_week = nil + expect(subject).not_to be_valid + end + + it "validates every_nth_day falls within range" do + (1..4).step(1) do |n| + subject.every_nth_day = n + expect(subject).to be_valid + end + subject.every_nth_day = -1 + expect(subject).to be_valid + subject.every_nth_day = 0 + expect(subject).not_to be_valid + subject.every_nth_day = 5 + expect(subject).not_to be_valid + subject.every_nth_day = nil + expect(subject).not_to be_valid + end + + it "skips validating day_of_month" do + subject.day_of_month = nil + expect(subject).to be_valid + subject.day_of_month = -1 + expect(subject).to be_valid + subject.day_of_month = 29 + expect(subject).to be_valid + end + end + end +end \ No newline at end of file From da9d20c35c9dcb75754f3cef045ace99ccaf9ca7 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sun, 6 Jul 2025 08:48:36 -0700 Subject: [PATCH 76/94] Removed old concerns and associated tests --- app/models/concerns/deadlinable.rb | 93 ------- app/models/concerns/reminder_scheduleable.rb | 92 ------- spec/models/concerns/deadlinable_spec.rb | 271 ------------------- 3 files changed, 456 deletions(-) delete mode 100644 app/models/concerns/deadlinable.rb delete mode 100644 app/models/concerns/reminder_scheduleable.rb delete mode 100644 spec/models/concerns/deadlinable_spec.rb diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb deleted file mode 100644 index 98f59f7e11..0000000000 --- a/app/models/concerns/deadlinable.rb +++ /dev/null @@ -1,93 +0,0 @@ -module Deadlinable - extend ActiveSupport::Concern - MIN_DAY_OF_MONTH = 1 - MAX_DAY_OF_MONTH = 28 - EVERY_NTH_COLLECTION = [["First", 1], ["Second", 2], ["Third", 3], ["Fourth", 4], ["Last", -1]].freeze - DAY_OF_WEEK_COLLECTION = [["Sunday", 0], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]].freeze - EVERY_NTH_MONTH_COLLECTION = [["Monthly", 1], ["Every 2 months", 2], ["Every 3 months", 3], ["Every 4 months", 4], ["Every 5 months", 5], - ["Every 6 months", 6], ["Every 7 months", 7], ["Every 8 months", 8], ["Every 9 months", 9], ["Every 10 months", 10], ["Every 11 months", 11], - ["Every 12 months", 12]].freeze - NTH_TO_WORD_MAP = { - 1 => "First", - 2 => "Second", - 3 => "Third", - 4 => "Fourth", - -1 => "Last" - }.freeze - - included do - attr_accessor :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :every_nth_month, :start_date - validates :deadline_day, numericality: {only_integer: true, less_than_or_equal_to: MAX_DAY_OF_MONTH, - greater_than_or_equal_to: MIN_DAY_OF_MONTH, allow_nil: true} - validate :day_of_month_on_deadline_day?, if: -> { day_of_month.present? } - validate :day_of_month_is_within_range?, if: -> { day_of_month.present? } - validates :by_month_or_week, inclusion: {in: %w[day_of_month day_of_week]}, if: -> { by_month_or_week.present? } - validates :day_of_week, if: -> { day_of_week.present? }, inclusion: {in: %w[0 1 2 3 4 5 6]} - validates :every_nth_day, if: -> { every_nth_day.present? }, inclusion: {in: %w[1 2 3 4 -1]} - validates :every_nth_month, if: -> { every_nth_month.present? }, inclusion: {in: EVERY_NTH_MONTH_COLLECTION.map { |ar| ar[1].to_s }} - end - - def convert_to_reminder_schedule(day) - schedule = IceCube::Schedule.new - schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(day) - schedule.to_ical - end - - def show_description(ical) - schedule = IceCube::Schedule.from_ical(ical) - schedule.recurrence_rules.first.to_s - end - - def get_values_from_reminder_schedule - if reminder_schedule.blank? - self.start_date = Time.zone.today - return - end - - schedule = IceCube::Schedule.from_ical(reminder_schedule) - rule = schedule.recurrence_rules.first.instance_values - if rule.blank? - self.start_date = Time.zone.today - return - end - day_of_month = rule["validations"][:day_of_month]&.first&.value - - self.start_date = schedule.start_time - self.by_month_or_week = day_of_month ? "day_of_month" : "day_of_week" - self.day_of_month = day_of_month - self.day_of_week = rule["validations"][:day_of_week]&.first&.day - self.every_nth_day = rule["validations"][:day_of_week]&.first&.occ - self.every_nth_month = rule["validations"][:interval]&.first&.interval - end - - def create_schedule - schedule = IceCube::Schedule.new(start_date ? Time.zone.parse(start_date) : Time.zone.now.to_date) - return nil if by_month_or_week.blank? || every_nth_month.blank? - if by_month_or_week == "day_of_month" - return nil if day_of_month.blank? - schedule.add_recurrence_rule(IceCube::Rule.monthly(every_nth_month).day_of_month(day_of_month.to_i)) - else - return nil if day_of_week.blank? || every_nth_day.blank? - schedule.add_recurrence_rule(IceCube::Rule.monthly(every_nth_month).day_of_week(day_of_week.to_i => [every_nth_day.to_i])) - end - schedule.to_ical - rescue - nil - end - - private - - def day_of_month_on_deadline_day? - if by_month_or_week == "day_of_month" && day_of_month.to_i == deadline_day - errors.add(:day_of_month, "Reminder must not be the same as deadline date") - end - end - - def day_of_month_is_within_range? - # IceCube converts negative or zero days to valid days (e.g. -1 becomes the last day of the month, 0 becomes 1) - # The minimum check should no longer be necessary, but keeping it in case IceCube changes - if by_month_or_week == "day_of_month" && day_of_month.to_i < MIN_DAY_OF_MONTH || day_of_month.to_i > MAX_DAY_OF_MONTH - errors.add(:day_of_month, "Reminder day must be between #{MIN_DAY_OF_MONTH} and #{MAX_DAY_OF_MONTH}") - end - end -end diff --git a/app/models/concerns/reminder_scheduleable.rb b/app/models/concerns/reminder_scheduleable.rb deleted file mode 100644 index 90d8585543..0000000000 --- a/app/models/concerns/reminder_scheduleable.rb +++ /dev/null @@ -1,92 +0,0 @@ -module ReminderScheduleable - extend ActiveSupport::Concern - - included do - # Consider prefixing the REMINDER_SCHEDULE_FIELDS to avoid collisions elsewhere? - before_save :save_reminder_schedule_definition - after_save :reset_reminder_schedule_service - - validate :reminder_schedule_is_valid? - validates :deadline_day, numericality: { - only_integer: true, - less_than_or_equal_to: ReminderScheduleService::MAX_DAY_OF_MONTH, - greater_than_or_equal_to: ReminderScheduleService::MIN_DAY_OF_MONTH, - allow_nil: true - } - end - - # For now assume that you won't be setting individual fields. - # You'll only be updating the ReminderScheduleService via saving an object with params. - def every_nth_month = reminder_schedule&.every_nth_month - def every_nth_month=(x) - if reminder_schedule - reminder_schedule.every_nth_month = x - end - end - def start_date = reminder_schedule&.start_date - def start_date=(x) - if reminder_schedule - reminder_schedule.start_date = x - end - end - def by_month_or_week = reminder_schedule&.by_month_or_week - def by_month_or_week=(x) - if reminder_schedule - reminder_schedule.by_month_or_week = x - end - end - def day_of_month = reminder_schedule&.day_of_month - def day_of_month=(x) - if reminder_schedule - reminder_schedule.day_of_month = x - end - end - def day_of_week = reminder_schedule&.day_of_week - def day_of_week=(x) - if reminder_schedule - reminder_schedule.day_of_week = x - end - end - def every_nth_day = reminder_schedule&.every_nth_day - def every_nth_day=(x) - if reminder_schedule - reminder_schedule.every_nth_day = x - end - end - - def reminder_schedule - if reminder_schedule_definition.present? - @reminder_schedule_service ||= ReminderScheduleService.from_ical(reminder_schedule_definition, self) - end - @reminder_schedule_service ||= ReminderScheduleService.new(parent_object: self, start_date: Time.zone.today) - end - - def reminder_schedule_from_params - @reminder_schedule_service ||= ReminderScheduleService.new({ - parent_object: self, - every_nth_month: every_nth_month, - start_date: start_date, - by_month_or_week: by_month_or_week, - day_of_month: day_of_month, - day_of_week: day_of_week, - every_nth_day: every_nth_day, - }) - end - - def save_reminder_schedule_definition - self.reminder_schedule_definition = reminder_schedule_from_params.to_ical - end - - private - - def reminder_schedule_is_valid? - unless reminder_schedule_from_params.valid? - errors.merge!(reminder_schedule_from_params.errors) - end - end - - def reset_reminder_schedule_service - @reminder_schedule_service = nil - end - -end diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb deleted file mode 100644 index 96e7304ae6..0000000000 --- a/spec/models/concerns/deadlinable_spec.rb +++ /dev/null @@ -1,271 +0,0 @@ -RSpec.describe Deadlinable, type: :model do - let(:dummy_class) do - Class.new do - def self.name - "Dummy" - end - - include ActiveModel::Model - include Deadlinable - - attr_accessor :deadline_day, :reminder_schedule - - def deadline_day? - !!deadline_day - end - end - end - - subject(:dummy) { dummy_class.new } - let(:current_day) { Time.current } - let(:schedule) { IceCube::Schedule.new(current_day) } - - shared_examples "doesn't validate absent field" do |field_name| - it "doesn't validate the #{field_name} field when it isn't present" do - dummy.public_send(:"#{field_name}=", "") - expect(dummy).to be_valid - dummy.public_send(:"#{field_name}=", nil) - expect(dummy).to be_valid - end - end - - describe "validations" do - it "validates the deadline_day field" do - dummy.deadline_day = nil - expect(dummy).to be_valid - dummy.deadline_day = 1 - expect(dummy).to be_valid - dummy.deadline_day = 28 - expect(dummy).to be_valid - dummy.deadline_day = 0.1 - expect(dummy).not_to be_valid - dummy.deadline_day = -1 - expect(dummy).not_to be_valid - dummy.deadline_day = 50 - expect(dummy).not_to be_valid - end - - it "validates the by_month_or_week field" do - dummy.by_month_or_week = "day_of_month" - expect(dummy).to be_valid - dummy.by_month_or_week = "day_of_week" - expect(dummy).to be_valid - dummy.by_month_or_week = "other_string" - expect(dummy).not_to be_valid - end - - include_examples "doesn't validate absent field", "by_month_or_week" - - it "validates the day_of_week field" do - (0..6).step(1) do |day| - dummy.day_of_week = day.to_s - expect(dummy).to be_valid - end - dummy.day_of_week = "-1" - expect(dummy).not_to be_valid - dummy.day_of_week = "7" - expect(dummy).not_to be_valid - dummy.day_of_week = "other_string" - expect(dummy).not_to be_valid - end - - include_examples "doesn't validate absent field", "day_of_week" - - it "validates the every_nth_day field" do - (1..4).step(1) do |n| - dummy.every_nth_day = n.to_s - expect(dummy).to be_valid - end - dummy.every_nth_day = "-1" - expect(dummy).to be_valid - dummy.every_nth_day = "6" - expect(dummy).not_to be_valid - dummy.every_nth_day = "other_string" - expect(dummy).not_to be_valid - end - - include_examples "doesn't validate absent field", "every_nth_day" - - it "validates the every_nth_month field" do - (1..12).step(1) do |n| - dummy.every_nth_month = n.to_s - expect(dummy).to be_valid - end - dummy.every_nth_month = "-1" - expect(dummy).not_to be_valid - dummy.every_nth_month = "24" - expect(dummy).not_to be_valid - dummy.every_nth_month = "other_string" - expect(dummy).not_to be_valid - end - - include_examples "doesn't validate absent field", "every_nth_month" - - it "validates that day_of_month field falls within the range" do - dummy.by_month_or_week = "day_of_month" - dummy.day_of_month = "29" - - expect(dummy).not_to be_valid - expect(dummy.errors.added?(:day_of_month, "Reminder day must be between 1 and 28")).to be_truthy - - dummy.day_of_month = "-1" - expect(dummy).not_to be_valid - expect(dummy.errors.added?(:day_of_month, "Reminder day must be between 1 and 28")).to be_truthy - end - - it "validates that day_of_month field is not the same as deadline_day" do - dummy.by_month_or_week = "day_of_month" - dummy.deadline_day = 14 - dummy.day_of_month = "14" - - expect(dummy).not_to be_valid - expect(dummy.errors.added?(:day_of_month, "Reminder must not be the same as deadline date")).to be_truthy - end - end - - it "convert_to_reminder_schedule returns by month day schedule in ICAL format" do - travel_to Time.zone.local(2020, 10, 10) - expect(dummy.convert_to_reminder_schedule(10)).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" - end - - it "show_description returns textual description of rule in ICAL format" do - ical_schedule = "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" - expect(dummy.show_description(ical_schedule)).to eq "Monthly on the 10th day of the month" - end - - it "get_values_from_reminder_schedule sets deadlineable's start_date to today if there is no schedule" do - dummy.get_values_from_reminder_schedule - expect(dummy.start_date).to eq(Time.zone.today) - dummy.reminder_schedule = "notavalidschedule" - dummy.get_values_from_reminder_schedule - expect(dummy.start_date).to eq(Time.zone.today) - end - - context "with an existing day_of_month schedule" do - before do - dummy.reminder_schedule = "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" - end - - it "get_values_from_reminder_schedule sets deadlineable's fields with values stored in ICAL schedule" do - dummy.get_values_from_reminder_schedule - expect(dummy.start_date).to eq(Time.zone.local(2020, 10, 10)) - expect(dummy.by_month_or_week).to eq("day_of_month") - expect(dummy.day_of_month).to eq(10) - expect(dummy.day_of_week).to eq(nil) - expect(dummy.every_nth_day).to eq(nil) - expect(dummy.every_nth_month).to eq(1) - end - end - - context "with an existing day_of_week schedule" do - before do - dummy.reminder_schedule = "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYDAY=3WE" - end - - it "get_values_from_reminder_schedule sets deadlineable's fields with values stored in ICAL schedule" do - dummy.get_values_from_reminder_schedule - expect(dummy.start_date).to eq(Time.zone.local(2020, 10, 10)) - expect(dummy.by_month_or_week).to eq("day_of_week") - expect(dummy.day_of_month).to eq(nil) - expect(dummy.day_of_week).to eq(3) - expect(dummy.every_nth_day).to eq(3) - expect(dummy.every_nth_month).to eq(1) - end - end - - context "by day of month" do - before do - dummy.by_month_or_week = "day_of_month" - end - - context "with a specified start date" do - before do - dummy.start_date = "2020/10/10" - end - - it "create_schedule returns schedule in ICAL format" do - dummy.day_of_month = "10" - dummy.every_nth_month = "1" - expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" - dummy.day_of_month = "15" - dummy.every_nth_month = "3" - expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15" - end - end - - context "without a specified start date" do - before do - travel_to Time.zone.local(2020, 11, 11) - end - - it "create_schedule returns schedule in ICAL format" do - dummy.day_of_month = "10" - dummy.every_nth_month = "1" - expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201111T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" - dummy.day_of_month = "15" - dummy.every_nth_month = "3" - expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201111T000000\nRRULE:FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15" - end - end - - it "create_schedule returns nil if needed fields are missing" do - dummy.day_of_month = "10" - expect(dummy.create_schedule).to eq nil - dummy.day_of_month = nil - dummy.every_nth_month = "1" - expect(dummy.create_schedule).to eq nil - end - end - - context "by day of week" do - before do - dummy.by_month_or_week = "day_of_week" - end - - context "with a specified start date" do - before do - dummy.start_date = "2020/10/10" - end - - it "create_schedule returns schedule in ICAL format" do - dummy.day_of_week = "0" - dummy.every_nth_day = "1" - dummy.every_nth_month = "1" - expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYDAY=1SU" - dummy.day_of_week = "3" - dummy.every_nth_day = "3" - dummy.every_nth_month = "1" - expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYDAY=3WE" - end - end - - context "without a specified start date" do - before do - travel_to Time.zone.local(2020, 11, 11) - end - - it "create_schedule returns schedule in ICAL format" do - dummy.day_of_week = "0" - dummy.every_nth_day = "1" - dummy.every_nth_month = "1" - expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201111T000000\nRRULE:FREQ=MONTHLY;BYDAY=1SU" - dummy.day_of_week = "3" - dummy.every_nth_day = "3" - dummy.every_nth_month = "1" - expect(dummy.create_schedule).to eq "DTSTART;TZID=#{Time.zone.now.zone}:20201111T000000\nRRULE:FREQ=MONTHLY;BYDAY=3WE" - end - end - - it "create_schedule returns nil if needed fields are missing" do - dummy.day_of_week = "0" - dummy.every_nth_day = "1" - expect(dummy.create_schedule).to eq nil - dummy.every_nth_day = nil - dummy.every_nth_month = "1" - expect(dummy.create_schedule).to eq nil - dummy.day_of_week = nil - dummy.every_nth_day = "1" - expect(dummy.create_schedule).to eq nil - end - end -end From ae881acb2817310be494bd97a86e8f1d3e692c4a Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Tue, 8 Jul 2025 08:08:33 -0700 Subject: [PATCH 77/94] Changes made by linter --- app/models/organization.rb | 2 +- app/models/partner_group.rb | 2 +- app/services/reminder_schedule_service.rb | 19 +++--- ...tch_partners_to_remind_now_service_spec.rb | 10 +-- .../reminder_schedule_service_spec.rb | 65 ++++++++++--------- .../deadline_day_fields_shared_example.rb | 15 ++--- 6 files changed, 59 insertions(+), 54 deletions(-) diff --git a/app/models/organization.rb b/app/models/organization.rb index 4d77a46a72..db2f226751 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -249,7 +249,7 @@ def display_last_distribution_date def reminder_schedule if reminder_schedule_definition.present? - @reminder_schedule_service ||= ReminderScheduleService.from_ical(reminder_schedule_definition) + @reminder_schedule_service ||= ReminderScheduleService.from_ical(reminder_schedule_definition) end @reminder_schedule_service ||= ReminderScheduleService.new(start_date: Time.zone.today) end diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index 158324b1f3..3c4b58bbf6 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -34,7 +34,7 @@ class PartnerGroup < ApplicationRecord def reminder_schedule if reminder_schedule_definition.present? - @reminder_schedule_service ||= ReminderScheduleService.from_ical(reminder_schedule_definition) + @reminder_schedule_service ||= ReminderScheduleService.from_ical(reminder_schedule_definition) end @reminder_schedule_service ||= ReminderScheduleService.new(start_date: Time.zone.today) end diff --git a/app/services/reminder_schedule_service.rb b/app/services/reminder_schedule_service.rb index f140ca8c0c..8c01a8df2e 100644 --- a/app/services/reminder_schedule_service.rb +++ b/app/services/reminder_schedule_service.rb @@ -21,13 +21,13 @@ class ReminderScheduleService :by_month_or_week, :day_of_month, :day_of_week, - :every_nth_day, + :every_nth_day ].freeze - attr_accessor *ReminderScheduleService::REMINDER_SCHEDULE_FIELDS + attr_accessor(*ReminderScheduleService::REMINDER_SCHEDULE_FIELDS) include ActiveModel::Validations - + validate :every_nth_month_within_range? validate :start_date_is_valid_date_or_date_string? validates :by_month_or_week, inclusion: {in: %w[day_of_month day_of_week]} @@ -35,7 +35,7 @@ class ReminderScheduleService validate :day_of_month_is_within_range?, if: -> { @by_month_or_week == "day_of_month" } validate :day_of_week_is_within_range?, if: -> { @by_month_or_week == "day_of_week" } validate :every_nth_day_is_within_range?, if: -> { @by_month_or_week == "day_of_week" } - + def initialize(parameter_hash) @every_nth_month = parameter_hash[:every_nth_month] @start_date = parameter_hash[:start_date] @@ -65,12 +65,12 @@ def self.from_ical(ical) by_month_or_week: day_of_month ? "day_of_month" : "day_of_week", day_of_month: day_of_month, day_of_week: rule["validations"][:day_of_week]&.first&.day, - every_nth_day: rule["validations"][:day_of_week]&.first&.occ, + every_nth_day: rule["validations"][:day_of_week]&.first&.occ }) end def []=(key, val) - self.send("#{key}=", val) + send("#{key}=", val) end def assign_attributes(attrs) @@ -129,15 +129,14 @@ def day_of_month_is_within_range? end def day_of_week_is_within_range? - unless day_of_week.present? && ([0, 1, 2, 3, 4, 5, 6,].include? day_of_week.to_i) + unless day_of_week.present? && ([0, 1, 2, 3, 4, 5, 6].include? day_of_week.to_i) errors.add(:day_of_week, "Day of week must be one of #{DAY_OF_WEEK_COLLECTION}") end end def every_nth_day_is_within_range? - unless every_nth_day.present? && ([1, 2, 3, 4, -1,].include? every_nth_day.to_i) + unless every_nth_day.present? && ([1, 2, 3, 4, -1].include? every_nth_day.to_i) errors.add(:every_nth_day, "Every Nth day must be one of #{EVERY_NTH_COLLECTION}") end end - -end \ No newline at end of file +end diff --git a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb index 0ad4586a9e..a032eb69a0 100644 --- a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb +++ b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb @@ -9,7 +9,7 @@ context "that has an organization with a global reminder & deadline" do context "that is for today" do before do - partner.organization.update(deadline_day:current_day + 2) + partner.organization.update(deadline_day: current_day + 2) partner.organization.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_month", day_of_month: current_day, @@ -25,7 +25,7 @@ context "as matched by day of the week" do before do - partner.organization.update(deadline_day:current_day + 2) + partner.organization.update(deadline_day: current_day + 2) partner.organization.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_week", day_of_week: 2, @@ -90,7 +90,7 @@ partner_group.save partner_group.partners << partner - partner.organization.update(deadline_day:current_day + 2) + partner.organization.update(deadline_day: current_day + 2) partner.organization.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_month", day_of_month: current_day - 1, @@ -130,7 +130,7 @@ partner_group.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_month", day_of_month: current_day, - every_nth_month: 1, + every_nth_month: 1 }) partner_group.save partner_group.partners << partner @@ -170,7 +170,7 @@ partner_group.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_month", day_of_month: current_day - 1, - every_nth_month: 1, + every_nth_month: 1 }) partner_group.save partner_group.partners << partner diff --git a/spec/services/reminder_schedule_service_spec.rb b/spec/services/reminder_schedule_service_spec.rb index f91673684c..a5ac562e7e 100644 --- a/spec/services/reminder_schedule_service_spec.rb +++ b/spec/services/reminder_schedule_service_spec.rb @@ -1,24 +1,29 @@ RSpec.describe ReminderScheduleService, type: :service do - - let(:day_of_month_schedule) { ReminderScheduleService.new({ - every_nth_month: 1, - by_month_or_week: "day_of_month", - day_of_month: 10 - }) } - let(:day_of_week_schedule) { ReminderScheduleService.new({ - every_nth_month: 1, - by_month_or_week: "day_of_week", - day_of_week: 0, - every_nth_day: 1 - }) } - let(:empty_schedule) { ReminderScheduleService.new({}) } - - describe "initialize" do - let(:subject) { ReminderScheduleService.new({ + let(:day_of_month_schedule) { + ReminderScheduleService.new({ every_nth_month: 1, by_month_or_week: "day_of_month", day_of_month: 10 - }) } + }) + } + let(:day_of_week_schedule) { + ReminderScheduleService.new({ + every_nth_month: 1, + by_month_or_week: "day_of_week", + day_of_week: 0, + every_nth_day: 1 + }) + } + let(:empty_schedule) { ReminderScheduleService.new({}) } + + describe "initialize" do + let(:subject) { + ReminderScheduleService.new({ + every_nth_month: 1, + by_month_or_week: "day_of_month", + day_of_month: 10 + }) + } it "returns a ReminderScheduleService instance" do expect(subject).to be_a_kind_of(ReminderScheduleService) @@ -33,15 +38,17 @@ end describe "from_ical" do - let(:subject) { ReminderScheduleService.from_ical( - "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" - ) } + let(:subject) { + ReminderScheduleService.from_ical( + "DTSTART;TZID=#{Time.zone.now.zone}:20201010T000000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=10" + ) + } it "returns a ReminderScheduleService instance" do expect(subject).to be_a_kind_of(ReminderScheduleService) expect(subject.every_nth_month).to eq 1 expect(subject.day_of_month).to eq 10 - expect(subject.start_date).to be_within(1.second).of Time.zone.local(2020,10,10) + expect(subject.start_date).to be_within(1.second).of Time.zone.local(2020, 10, 10) end it "returns nil if a blank or invalid ical string is provided", :aggregate_failures do @@ -76,7 +83,7 @@ result = day_of_month_schedule.to_icecube_schedule expect(result).to be_a_kind_of(IceCube::Schedule) end - + it "returns nil if the ReminderScheduleService isn't valid" do expect(ReminderScheduleService.new({}).to_icecube_schedule).to eq nil end @@ -116,7 +123,7 @@ it "returns true if all fields, except start_date, are nil" do expect(empty_schedule.no_fields_filled_out?).to be true end - + it "returns false otherwise", :aggregate_failures do empty_schedule.every_nth_month = 1 expect(empty_schedule.no_fields_filled_out?).to be false @@ -131,7 +138,7 @@ end context "with a day_of_month schedule" do - let(:subject) {day_of_month_schedule} + let(:subject) { day_of_month_schedule } it "returns true if the schedule occurs on the provided date", :aggregate_failures do expect(subject.occurs_on?(Time.zone.local(2020, 10, 10))).to be true @@ -147,7 +154,7 @@ end context "with a day_of_week schedule" do - let(:subject) {day_of_week_schedule} + let(:subject) { day_of_week_schedule } it "returns true if the schedule occurs on the provided date", :aggregate_failures do expect(subject.occurs_on?(Time.zone.local(2020, 2, 2))).to be true @@ -207,9 +214,9 @@ day_of_week_schedule.by_month_or_week = nil expect(day_of_week_schedule).not_to be_valid end - + context "on a day_of_month schedule" do - let(:subject) {day_of_month_schedule} + let(:subject) { day_of_month_schedule } it "validates that day_of_month falls within range" do (1..28).step(1) do |n| @@ -244,7 +251,7 @@ end context "on a day_of_week schedule" do - let(:subject) {day_of_week_schedule} + let(:subject) { day_of_week_schedule } it "validates day_of_week falls within range" do (0..6).step(1) do |n| @@ -284,4 +291,4 @@ end end end -end \ No newline at end of file +end diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 4821cf5ba2..bb1e81a9cd 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -71,23 +71,22 @@ end describe "calculates the reminder and deadline dates" do - # The reminder day (the #{form_prefix}_reminder_schedule_service_day_of_month field ) has to be less than or equal to 28. # These functions are implemented to calculate dates prior or after @now that do not fall on a # date with a day greater than 28. - def safe_add_days( date, num ) - result = date += num.days + def safe_add_days(date, num) + result = date + num.days if result.day > 28 - result = result.change({day: 1+num}) + result = result.change({day: 1 + num}) result += 1.month end result end - def safe_subtract_days( date, num ) - result = date -= num.days + def safe_subtract_days(date, num) + result = date - num.days if result.day > 28 - result = result.change({day: 28-num}) + result = result.change({day: 28 - num}) result -= 1.month end result @@ -133,7 +132,7 @@ def safe_subtract_days( date, num ) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) start_date = safe_add_days(@now, 1) - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (start_date).strftime("%Y-%m-%d") + fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(start_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) From 3a4fcff75029099672c6f3e94c59566775b5d129 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Tue, 8 Jul 2025 08:42:02 -0700 Subject: [PATCH 78/94] Addressed linter's warning about using mixed logical operators in an unless condition --- app/models/organization.rb | 6 ++++-- app/models/partner_group.rb | 6 ++++-- app/services/reminder_schedule_service.rb | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/models/organization.rb b/app/models/organization.rb index db2f226751..68c71d841e 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -263,8 +263,10 @@ def reminder_schedule_is_empty_or_valid? # The schedule shouldn't be validated if the user hasn't touched that form, # so if by_month_or_week is still the default (nil) assume the user didn't # intend to fill out that form and don't validate. - unless reminder_schedule.no_fields_filled_out? || reminder_schedule.by_month_or_week.nil? || (reminder_schedule.valid? && deadline_not_on_reminder_date?) - errors.merge!(reminder_schedule.errors) + unless reminder_schedule.no_fields_filled_out? || reminder_schedule.by_month_or_week.nil? + unless reminder_schedule.valid? && deadline_not_on_reminder_date? + errors.merge!(reminder_schedule.errors) + end end end diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index 3c4b58bbf6..245f3da4fe 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -48,8 +48,10 @@ def reminder_schedule_is_empty_or_valid? # The schedule shouldn't be validated if the user hasn't touched that form, # so if by_month_or_week is still the default (nil) assume the user didn't # intend to fill out that form and don't validate. - unless reminder_schedule.no_fields_filled_out? || reminder_schedule.by_month_or_week.nil? || (reminder_schedule.valid? && deadline_not_on_reminder_date?) - errors.merge!(reminder_schedule.errors) + unless reminder_schedule.no_fields_filled_out? || reminder_schedule.by_month_or_week.nil? + unless reminder_schedule.valid? && deadline_not_on_reminder_date? + errors.merge!(reminder_schedule.errors) + end end end diff --git a/app/services/reminder_schedule_service.rb b/app/services/reminder_schedule_service.rb index 8c01a8df2e..7a1f3e9f24 100644 --- a/app/services/reminder_schedule_service.rb +++ b/app/services/reminder_schedule_service.rb @@ -115,7 +115,7 @@ def every_nth_month_within_range? end def start_date_is_valid_date_or_date_string? - unless start_date.present? && (start_date.respond_to?(:strftime) || Time.zone.parse(start_date)) + if !(start_date.present? && (start_date.respond_to?(:strftime) || Time.zone.parse(start_date))) errors.add(:start_date, "Start date must be a valid date string") end end From 3431b11c60bb3809a21f53173c9c30697281863a Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 17 Jul 2025 07:41:11 -0700 Subject: [PATCH 79/94] Reworked DeadlineService and exposed IceCube schedule next_occurrence to show user next reminder and deadline date in views --- app/mailers/reminder_deadline_mailer.rb | 2 +- app/services/deadline_service.rb | 15 ++++++++------- app/services/reminder_schedule_service.rb | 4 ++++ app/views/organizations/_details.html.erb | 13 +++++++++++++ app/views/partners/_partner_groups_table.html.erb | 13 ++++++++++++- app/views/shared/_deadline_day_fields.html.erb | 2 +- spec/services/deadline_service_spec.rb | 2 +- spec/system/organization_system_spec.rb | 2 +- spec/system/partner_system_spec.rb | 2 +- 9 files changed, 42 insertions(+), 13 deletions(-) diff --git a/app/mailers/reminder_deadline_mailer.rb b/app/mailers/reminder_deadline_mailer.rb index a7268bbb94..5df6f382af 100644 --- a/app/mailers/reminder_deadline_mailer.rb +++ b/app/mailers/reminder_deadline_mailer.rb @@ -15,7 +15,7 @@ def notify_deadline(partner) private def deadline_date(partner) - date = DeadlineService.new(partner: partner).next_deadline + date = DeadlineService.new(deadline_day: DeadlineService.get_deadline_for_partner(partner)).next_deadline return date if date diff --git a/app/services/deadline_service.rb b/app/services/deadline_service.rb index 751707b055..a801a787b4 100644 --- a/app/services/deadline_service.rb +++ b/app/services/deadline_service.rb @@ -1,18 +1,19 @@ class DeadlineService include ServiceObjectErrorsMixin - def initialize(partner:) - @partner = partner - @today = Time.zone.today + def initialize(deadline_day:, today: nil) + @deadline_day = deadline_day + @today = today || Time.zone.today end def next_deadline - day = @partner.partner_group&.deadline_day || - @partner.organization.deadline_day + return if @deadline_day.blank? - return if day.blank? + next_date(@deadline_day) + end - next_date(day) + def self.get_deadline_for_partner(partner) + partner.partner_group&.deadline_day || partner.organization.deadline_day end private diff --git a/app/services/reminder_schedule_service.rb b/app/services/reminder_schedule_service.rb index 7a1f3e9f24..ba6ae34226 100644 --- a/app/services/reminder_schedule_service.rb +++ b/app/services/reminder_schedule_service.rb @@ -106,6 +106,10 @@ def occurs_on?(date) to_icecube_schedule&.occurs_on?(date) end + def next_occurrence + to_icecube_schedule&.next_occurrence + end + private def every_nth_month_within_range? diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb index b9e2b8343b..522c23b18f 100644 --- a/app/views/organizations/_details.html.erb +++ b/app/views/organizations/_details.html.erb @@ -175,6 +175,11 @@

<%= fa_icon "calendar" %> <%= @organization.reminder_schedule&.show_description.blank? ? 'Not defined' : @organization.reminder_schedule&.show_description %> + <% if @organization.reminder_schedule.valid? %> + + <%= "Your next reminder date is #{@organization.reminder_schedule.next_occurrence&.strftime('%a %b %d %Y')}." %> + + <% end %>

@@ -182,6 +187,14 @@

<%= fa_icon "calendar" %> <%= @organization.deadline_day.blank? ? 'Not defined' : "The #{@organization.deadline_day.ordinalize} after the reminder." %> + + <% if @organization.deadline_day && @organization.reminder_schedule.valid? %> + <%= "Your next deadline date is #{DeadlineService.new( + deadline_day: @organization.deadline_day, + today: @organization.reminder_schedule.next_occurrence).next_deadline&.strftime('%a %b %d %Y')}." + %> + <% end %> +

diff --git a/app/views/partners/_partner_groups_table.html.erb b/app/views/partners/_partner_groups_table.html.erb index d51b081d8f..b293460acc 100644 --- a/app/views/partners/_partner_groups_table.html.erb +++ b/app/views/partners/_partner_groups_table.html.erb @@ -45,11 +45,22 @@ <% if pg.send_reminders %> - <% if pg.reminder_schedule.present? %> + <% if pg.reminder_schedule.valid? %> Reminder emails are sent <%= pg.reminder_schedule&.show_description %>. + + <%= "Your next reminder date is #{pg.reminder_schedule.next_occurrence&.strftime('%a %b %d %Y')}." %> +
<% end %> Deadlines are the <%= pg.deadline_day.ordinalize %> after the reminder. + <% if pg.deadline_day && pg.reminder_schedule.valid? %> + + <%= "Your next deadline date is #{DeadlineService.new( + deadline_day: pg.deadline_day, + today: pg.reminder_schedule.next_occurrence).next_deadline&.strftime('%a %b %d %Y')}." + %> + + <% end %> <% else %> No <% end %> diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index d7c9896b04..f6e9cda38c 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -10,7 +10,7 @@ default: 1, input_html: {style: 'width: 200px', "data-deadline-day-target" => "everyNthMonth", "data-action" => "deadline-day#sourceChange"} %> - <%= f.label :start_date, 'Reminder start being sent after:' %> + <%= f.label :start_date, 'Reminders start being sent after:' %> <%= f.input :start_date, as: :date, label: false, diff --git a/spec/services/deadline_service_spec.rb b/spec/services/deadline_service_spec.rb index f584cbe367..84a284bf2d 100644 --- a/spec/services/deadline_service_spec.rb +++ b/spec/services/deadline_service_spec.rb @@ -9,7 +9,7 @@ end shared_examples "calculates the next deadline" do - subject(:deadline) { described_class.new(partner: partner).next_deadline } + subject(:deadline) { described_class.new(deadline_day: described_class.get_deadline_for_partner(partner)).next_deadline } context "when the deadline is after today" do before { expected_receiver[:deadline_day] = 11 } diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index 7741132c54..41a047dfa6 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -116,7 +116,7 @@ def post_form_submit travel_to shown_recurrence_date expect(Partners::FetchPartnersToRemindNowService.new.fetch).to include(partner) - expect(DeadlineService.new(partner: partner).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date + expect(DeadlineService.new(deadline_day: DeadlineService.get_deadline_for_partner(partner)).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date end it 'can select if the org repackages essentials' do diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 86ff064d50..11fa44a414 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -708,7 +708,7 @@ travel_to shown_recurrence_date expect(Partners::FetchPartnersToRemindNowService.new.fetch).to include(partner) - expect(DeadlineService.new(partner: partner).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date + expect(DeadlineService.new(deadline_day: DeadlineService.get_deadline_for_partner(partner)).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date end end end From 0ca7cb79582906719f9872f549e4b428eb0d9637 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Fri, 18 Jul 2025 07:49:07 -0700 Subject: [PATCH 80/94] Added tests to verify reminder and deadilne dates added to organization and partner group views --- spec/system/organization_system_spec.rb | 31 +++++++++++++++ spec/system/partner_system_spec.rb | 53 ++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index 41a047dfa6..0242c01b20 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -66,6 +66,34 @@ expect(page).to have_content("Other emails") expect(page).to have_content("Printing") expect(page).to have_content("Annual Survey") + + expect(page).not_to have_content("Your next reminder date is ") + expect(page).not_to have_content("Your next deadline date is ") + end + + context "with a reminder schedule" do + before do + travel_to Time.zone.local(2020, 10, 10) + valid_reminder_schedule = ReminderScheduleService.new({ + by_month_or_week: "day_of_month", + every_nth_month: 1, + day_of_month: 20 + }).to_ical + organization.update(reminder_schedule_definition: valid_reminder_schedule) + end + + it "reports the next date a reminder email will be sent" do + visit organization_path + expect(page).to have_content("Your next reminder date is Tue Oct 20 2020.") + expect(page).not_to have_content("Your next deadline date is Sun Oct 25 2020.") + end + + it "reports the deadline date that will be included in the next reminder email" do + organization.update(deadline_day: 25) + visit organization_path + expect(page).to have_content("Your next reminder date is Tue Oct 20 2020.") + expect(page).to have_content("Your next deadline date is Sun Oct 25 2020.") + end end end @@ -117,6 +145,9 @@ def post_form_submit expect(Partners::FetchPartnersToRemindNowService.new.fetch).to include(partner) expect(DeadlineService.new(deadline_day: DeadlineService.get_deadline_for_partner(partner)).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date + + expect(page).to have_content("Your next reminder date is #{reminder_text}.") + expect(page).to have_content("Your next deadline date is #{deadline_text}.") end it 'can select if the org repackages essentials' do diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 11fa44a414..50354602b0 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -611,13 +611,57 @@ sign_in(user) end - let!(:item_category_1) { create(:item_category, organization: organization) } - let!(:item_category_2) { create(:item_category, organization: organization) } + let!(:item_category_1) { create(:item_category, name: "Category One", organization: organization) } + let!(:item_category_2) { create(:item_category, name: "Category Two", organization: organization) } let!(:items_in_category_1) { create_list(:item, 3, item_category_id: item_category_1.id) } let!(:items_in_category_2) { create_list(:item, 3, item_category_id: item_category_2.id) } + describe 'viewing the partner groups' do + let!(:partner_group_1) { create(:partner_group, name: "Group One", organization: organization) } + let!(:partner_1) { create(:partner, name: "Partner One", partner_group: partner_group_1) } + before do + partner_group_1.item_categories << item_category_1 + end + + it "shows the name, member partners, and item categories" do + visit partners_path + click_on 'Groups' + expect(page).to have_content("Group One") + expect(page).to have_content("Partner One") + expect(page).to have_content("Category One") + + expect(page).not_to have_content("Your next reminder date is Tue Oct 20 2020.") + expect(page).not_to have_content("Your next deadline date is Sun Oct 25 2020.") + end + + context "with a reminder schedule" do + before do + travel_to Time.zone.local(2020, 10, 10) + valid_reminder_schedule = ReminderScheduleService.new({ + by_month_or_week: "day_of_month", + every_nth_month: 1, + day_of_month: 20 + }).to_ical + partner_group_1.update( + send_reminders: true, + deadline_day: 25, + reminder_schedule_definition: valid_reminder_schedule + ) + end + + it "reports the next date a reminder email will be sent the deadline date that will be included in the next reminder email" do + visit partners_path + click_on 'Groups' + expect(page).to have_content("Your next reminder date is Tue Oct 20 2020.") + expect(page).to have_content("Your next deadline date is Sun Oct 25 2020.") + end + end + + end + describe 'creating a new partner group' do it 'should allow creating a new partner group with item categories' do + travel_to Time.zone.local(2020, 10, 10) visit partners_path click_on 'Groups' @@ -638,6 +682,8 @@ assert page.has_content? 'Group Name', wait: page_content_wait assert page.has_content? 'Test Group' assert page.has_content? item_category_2.name + expect(page).to have_content("Your next reminder date is Sun Nov 01 2020.") + expect(page).to have_content("Your next deadline date is Wed Nov 25 2020.") end end @@ -709,6 +755,9 @@ expect(Partners::FetchPartnersToRemindNowService.new.fetch).to include(partner) expect(DeadlineService.new(deadline_day: DeadlineService.get_deadline_for_partner(partner)).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date + + expect(page).to have_content("Your next reminder date is #{reminder_text}.") + expect(page).to have_content("Your next deadline date is #{deadline_text}.") end end end From d81a58814a6836ee2fed1eefd3f94f16ca14737a Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 21 Jul 2025 08:35:16 -0700 Subject: [PATCH 81/94] Updated the reminder schedule form to instruct users how to set the start date for non-monthly schedules and warn them about same-day schedules likely not firing --- app/views/shared/_deadline_day_fields.html.erb | 8 +++++++- docs/user_guide/bank/pm_partner_reminders.md | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index f6e9cda38c..865f6472dc 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -10,13 +10,19 @@ default: 1, input_html: {style: 'width: 200px', "data-deadline-day-target" => "everyNthMonth", "data-action" => "deadline-day#sourceChange"} %> - <%= f.label :start_date, 'Reminders start being sent after:' %> + <%= f.label :start_date, 'Reminders start on or after:' %> <%= f.input :start_date, as: :date, label: false, html5: true, wrapper: :input_group, input_html: {"data-deadline-day-target" => "startDate", "data-action" => "deadline-day#sourceChange"} %> + + For non-monthly schedules, it is recommended you set this to to the first date you would like reminders to be sent. + + + Newly created/updated schedules likely won't sent a reminder the day they are created/updated. + <%= f.label :by_month_or_week, 'Send reminders on a specific day of the month (e.g. "the 5th") or a day of the week (eg "the first Tuesday")?' %>
diff --git a/docs/user_guide/bank/pm_partner_reminders.md b/docs/user_guide/bank/pm_partner_reminders.md index 579c5e5f64..b9b3744ca1 100644 --- a/docs/user_guide/bank/pm_partner_reminders.md +++ b/docs/user_guide/bank/pm_partner_reminders.md @@ -1,10 +1,14 @@ # Partner Reminder Emails You may configure a reminder schedule on an organization and/or Partner Group level. Partners who are covered by these categories, and who individually have reminders enabled, will receive an email based on the schedule, reminding them of the deadline for submitting requests. -You may configure the monthly frequency of reminders and the date of the month or weekday of the month they are sent. You may also configure the deadline date included in the email. +You may configure the monthly frequency of reminders, the date on or after which reminders will first be sent (refered to as the start date), the date of the month or weekday of the month they are sent, and the deadline date included in the email. As you fill out the form, it should show you a preview of the next time the reminder will be sent, and the deadline date that will be included in the email. +When configuring a non-monthly reminder schedule (every 2 months, every 3 months, etc.) it is recommended you set the start date to correspond to the the first date you would like reminders to be sent. For example, if the reminder is set to be every 3 months on the 14th, and it is currently January 21st, it is recommended to set the start date to one of Febuary 14th, March 14th, April 14th, etc. + +Be aware that due to how these schedules are checked, it is unlikely that a newly created or updated schedule set to send a reminder the day it is created or update will actually send that reminder. + ## Default deadline day (final day of month to submit Requests) This is the day which will be included in the reminder email message. From fca91bf40a616c15ab543b398760767d484f6417 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 21 Jul 2025 13:58:02 -0700 Subject: [PATCH 82/94] Changes made by linter --- app/views/organizations/_details.html.erb | 3 +-- app/views/partners/_partner_groups_table.html.erb | 3 +-- spec/system/organization_system_spec.rb | 8 ++++---- spec/system/partner_system_spec.rb | 5 ++--- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb index 522c23b18f..2609f1a331 100644 --- a/app/views/organizations/_details.html.erb +++ b/app/views/organizations/_details.html.erb @@ -191,8 +191,7 @@ <% if @organization.deadline_day && @organization.reminder_schedule.valid? %> <%= "Your next deadline date is #{DeadlineService.new( deadline_day: @organization.deadline_day, - today: @organization.reminder_schedule.next_occurrence).next_deadline&.strftime('%a %b %d %Y')}." - %> + today: @organization.reminder_schedule.next_occurrence).next_deadline&.strftime('%a %b %d %Y')}." %> <% end %>

diff --git a/app/views/partners/_partner_groups_table.html.erb b/app/views/partners/_partner_groups_table.html.erb index b293460acc..9e825530c8 100644 --- a/app/views/partners/_partner_groups_table.html.erb +++ b/app/views/partners/_partner_groups_table.html.erb @@ -57,8 +57,7 @@ <%= "Your next deadline date is #{DeadlineService.new( deadline_day: pg.deadline_day, - today: pg.reminder_schedule.next_occurrence).next_deadline&.strftime('%a %b %d %Y')}." - %> + today: pg.reminder_schedule.next_occurrence).next_deadline&.strftime('%a %b %d %Y')}." %> <% end %> <% else %> diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index 0242c01b20..24f52f5090 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -68,7 +68,7 @@ expect(page).to have_content("Annual Survey") expect(page).not_to have_content("Your next reminder date is ") - expect(page).not_to have_content("Your next deadline date is ") + expect(page).not_to have_content("Your next deadline date is ") end context "with a reminder schedule" do @@ -81,11 +81,11 @@ }).to_ical organization.update(reminder_schedule_definition: valid_reminder_schedule) end - + it "reports the next date a reminder email will be sent" do visit organization_path expect(page).to have_content("Your next reminder date is Tue Oct 20 2020.") - expect(page).not_to have_content("Your next deadline date is Sun Oct 25 2020.") + expect(page).not_to have_content("Your next deadline date is Sun Oct 25 2020.") end it "reports the deadline date that will be included in the next reminder email" do @@ -145,7 +145,7 @@ def post_form_submit expect(Partners::FetchPartnersToRemindNowService.new.fetch).to include(partner) expect(DeadlineService.new(deadline_day: DeadlineService.get_deadline_for_partner(partner)).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date - + expect(page).to have_content("Your next reminder date is #{reminder_text}.") expect(page).to have_content("Your next deadline date is #{deadline_text}.") end diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 50354602b0..6276481c31 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -648,7 +648,7 @@ reminder_schedule_definition: valid_reminder_schedule ) end - + it "reports the next date a reminder email will be sent the deadline date that will be included in the next reminder email" do visit partners_path click_on 'Groups' @@ -656,7 +656,6 @@ expect(page).to have_content("Your next deadline date is Sun Oct 25 2020.") end end - end describe 'creating a new partner group' do @@ -755,7 +754,7 @@ expect(Partners::FetchPartnersToRemindNowService.new.fetch).to include(partner) expect(DeadlineService.new(deadline_day: DeadlineService.get_deadline_for_partner(partner)).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date - + expect(page).to have_content("Your next reminder date is #{reminder_text}.") expect(page).to have_content("Your next deadline date is #{deadline_text}.") end From 45f77b6f71760f22207d23f22759f0000b3d740e Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 28 Jul 2025 11:55:39 -0700 Subject: [PATCH 83/94] Removed start_date and every_nth_month fields from ReminderScheduleService, now assuming all schedules are monthyl and start today; removed associated tests --- .../controllers/deadline_day_controller.js | 21 +- app/services/reminder_schedule_service.rb | 36 +-- .../shared/_deadline_day_fields.html.erb | 23 -- ...tch_partners_to_remind_now_service_spec.rb | 18 +- .../reminder_schedule_service_spec.rb | 40 +--- .../deadline_day_fields_shared_example.rb | 208 +----------------- spec/system/organization_system_spec.rb | 1 - spec/system/partner_system_spec.rb | 1 - 8 files changed, 21 insertions(+), 327 deletions(-) diff --git a/app/javascript/controllers/deadline_day_controller.js b/app/javascript/controllers/deadline_day_controller.js index 9cc4f78a42..11357f54f1 100644 --- a/app/javascript/controllers/deadline_day_controller.js +++ b/app/javascript/controllers/deadline_day_controller.js @@ -15,7 +15,7 @@ const WEEKDAY_NUM_TO_OBJ = { export default class extends Controller { static targets = [ - 'startDate', 'everyNthMonth', 'byDayOfMonth', 'byDayOfWeek', 'dayOfMonthFields', 'dayOfMonth', + 'byDayOfMonth', 'byDayOfWeek', 'dayOfMonthFields', 'dayOfMonth', 'dayOfWeekFields', 'everyNthDay', 'dayOfWeek', 'deadlineDay', 'reminderText', 'deadlineText' ] @@ -39,22 +39,15 @@ export default class extends Controller { let reminder_date = null; let deadline_date = null; - let match = this.startDateTarget.value.match(this.constructor.dateParser); - let startDate = new Date( - match[1], - match[2]-1, // Subtracting 1 because the Date constructor uses month indices, but year and day numbers - match[3] - ); - let monthlyInterval = parseInt(this.everyNthMonthTarget.value); - // Calculate the next reminder date after the start date, or the current date, whichever is greater. - // Do it this way to avoid the next reminder/deadline date being a date in the past. - let today = new Date() - let untilDate = new Date( Math.max(...[ startDate, today ]) ) + // TODO: Add comment explaining assumptions about monthylInterval and today? + let monthlyInterval = 1; + let today = new Date(); + let untilDate = new Date( today ); untilDate.setMonth( untilDate.getMonth() + monthlyInterval + 1 ) if (this.byDayOfMonthTarget.checked && this.dayOfMonthTarget.value) { const rule = new RRule({ - dtstart: startDate, + dtstart: today, freq: RRule.MONTHLY, interval: monthlyInterval, bymonthday: parseInt(this.dayOfMonthTarget.value), @@ -64,7 +57,7 @@ export default class extends Controller { } if (this.byDayOfWeekTarget.checked && this.everyNthDayTarget.value && (this.dayOfWeekTarget.value)) { const rule = new RRule({ - dtstart: startDate, + dtstart: today, freq: RRule.MONTHLY, interval: monthlyInterval, byweekday: WEEKDAY_NUM_TO_OBJ[ parseInt(this.dayOfWeekTarget.value) ].nth( parseInt(this.everyNthDayTarget.value) ), diff --git a/app/services/reminder_schedule_service.rb b/app/services/reminder_schedule_service.rb index ba6ae34226..bd91b2355e 100644 --- a/app/services/reminder_schedule_service.rb +++ b/app/services/reminder_schedule_service.rb @@ -3,9 +3,6 @@ class ReminderScheduleService MAX_DAY_OF_MONTH = 28 EVERY_NTH_COLLECTION = [["First", 1], ["Second", 2], ["Third", 3], ["Fourth", 4], ["Last", -1]].freeze DAY_OF_WEEK_COLLECTION = [["Sunday", 0], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]].freeze - EVERY_NTH_MONTH_COLLECTION = [["Monthly", 1], ["Every 2 months", 2], ["Every 3 months", 3], ["Every 4 months", 4], ["Every 5 months", 5], - ["Every 6 months", 6], ["Every 7 months", 7], ["Every 8 months", 8], ["Every 9 months", 9], ["Every 10 months", 10], ["Every 11 months", 11], - ["Every 12 months", 12]].freeze NTH_TO_WORD_MAP = { 1 => "First", 2 => "Second", @@ -16,8 +13,6 @@ class ReminderScheduleService # The list of fields which are part of the _deadline_day_fields.html.erb form REMINDER_SCHEDULE_FIELDS = [ - :every_nth_month, - :start_date, :by_month_or_week, :day_of_month, :day_of_week, @@ -28,8 +23,6 @@ class ReminderScheduleService include ActiveModel::Validations - validate :every_nth_month_within_range? - validate :start_date_is_valid_date_or_date_string? validates :by_month_or_week, inclusion: {in: %w[day_of_month day_of_week]} validates :day_of_month, if: -> { @by_month_or_week == "day_of_month" }, presence: true validate :day_of_month_is_within_range?, if: -> { @by_month_or_week == "day_of_month" } @@ -37,11 +30,6 @@ class ReminderScheduleService validate :every_nth_day_is_within_range?, if: -> { @by_month_or_week == "day_of_week" } def initialize(parameter_hash) - @every_nth_month = parameter_hash[:every_nth_month] - @start_date = parameter_hash[:start_date] - if !@start_date - @start_date = Time.zone.now - end @by_month_or_week = parameter_hash[:by_month_or_week] @day_of_month = parameter_hash[:day_of_month] @day_of_week = parameter_hash[:day_of_week] @@ -60,8 +48,6 @@ def self.from_ical(ical) day_of_month = rule["validations"][:day_of_month]&.first&.value ReminderScheduleService.new({ - every_nth_month: rule["validations"][:interval]&.first&.interval, - start_date: schedule.start_time, by_month_or_week: day_of_month ? "day_of_month" : "day_of_week", day_of_month: day_of_month, day_of_week: rule["validations"][:day_of_week]&.first&.day, @@ -81,11 +67,11 @@ def to_icecube_schedule unless valid? return nil end - schedule = IceCube::Schedule.new(start_date.respond_to?(:strftime) ? start_date : Time.zone.parse(start_date)) + schedule = IceCube::Schedule.new if by_month_or_week == "day_of_month" - schedule.add_recurrence_rule(IceCube::Rule.monthly(every_nth_month).day_of_month(day_of_month.to_i)) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(day_of_month.to_i)) else - schedule.add_recurrence_rule(IceCube::Rule.monthly(every_nth_month).day_of_week(day_of_week.to_i => [every_nth_day.to_i])) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(day_of_week.to_i => [every_nth_day.to_i])) end schedule end @@ -99,7 +85,7 @@ def show_description end def no_fields_filled_out? - every_nth_month.nil? && by_month_or_week.nil? && day_of_month.nil? && day_of_week.nil? && every_nth_day.nil? + by_month_or_week.nil? && day_of_month.nil? && day_of_week.nil? && every_nth_day.nil? end def occurs_on?(date) @@ -112,22 +98,10 @@ def next_occurrence private - def every_nth_month_within_range? - if every_nth_month.to_i < EVERY_NTH_MONTH_COLLECTION.first.last || every_nth_month.to_i > EVERY_NTH_MONTH_COLLECTION.last.last - errors.add(:every_nth_month, "Monthly frequence must be between #{EVERY_NTH_MONTH_COLLECTION.first.first} and #{EVERY_NTH_MONTH_COLLECTION.last.first}") - end - end - - def start_date_is_valid_date_or_date_string? - if !(start_date.present? && (start_date.respond_to?(:strftime) || Time.zone.parse(start_date))) - errors.add(:start_date, "Start date must be a valid date string") - end - end - def day_of_month_is_within_range? # IceCube converts negative or zero days to valid days (e.g. -1 becomes the last day of the month, 0 becomes 1) # The minimum check should no longer be necessary, but keeping it in case IceCube changes - if start_date.present? && (day_of_month.to_i < MIN_DAY_OF_MONTH || day_of_month.to_i > MAX_DAY_OF_MONTH) + if day_of_month.to_i < MIN_DAY_OF_MONTH || day_of_month.to_i > MAX_DAY_OF_MONTH errors.add(:day_of_month, "Reminder day must be between #{MIN_DAY_OF_MONTH} and #{MAX_DAY_OF_MONTH}") end end diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index 865f6472dc..fcfc508580 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -1,29 +1,6 @@
<%= parent_form.simple_fields_for parent_object.reminder_schedule do |f| %> - <%= f.label :every_nth_month, 'How frequently should reminders be sent (e.g. "monthly", "every 3 months", etc.)?' %> - <%= f.input :every_nth_month, - collection: ReminderScheduleService::EVERY_NTH_MONTH_COLLECTION, - class: "form-control", - label: false, - show_blank: true, - default: 1, - input_html: {style: 'width: 200px', "data-deadline-day-target" => "everyNthMonth", "data-action" => "deadline-day#sourceChange"} %> - - <%= f.label :start_date, 'Reminders start on or after:' %> - <%= f.input :start_date, - as: :date, - label: false, - html5: true, - wrapper: :input_group, - input_html: {"data-deadline-day-target" => "startDate", "data-action" => "deadline-day#sourceChange"} %> - - For non-monthly schedules, it is recommended you set this to to the first date you would like reminders to be sent. - - - Newly created/updated schedules likely won't sent a reminder the day they are created/updated. - - <%= f.label :by_month_or_week, 'Send reminders on a specific day of the month (e.g. "the 5th") or a day of the week (eg "the first Tuesday")?' %>
<%= f.radio_button :by_month_or_week, diff --git a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb index a032eb69a0..cba44f2bfc 100644 --- a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb +++ b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb @@ -12,8 +12,7 @@ partner.organization.update(deadline_day: current_day + 2) partner.organization.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_month", - day_of_month: current_day, - every_nth_month: 1 + day_of_month: current_day }) partner.organization.save end @@ -29,8 +28,7 @@ partner.organization.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_week", day_of_week: 2, - every_nth_day: 2, - every_nth_month: 1 + every_nth_day: 2 }) partner.organization.save end @@ -84,8 +82,7 @@ ) partner_group.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_month", - day_of_month: current_day, - every_nth_month: 1 + day_of_month: current_day }) partner_group.save partner_group.partners << partner @@ -93,8 +90,7 @@ partner.organization.update(deadline_day: current_day + 2) partner.organization.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_month", - day_of_month: current_day - 1, - every_nth_month: 1 + day_of_month: current_day - 1 }) partner.organization.save end @@ -129,8 +125,7 @@ ) partner_group.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_month", - day_of_month: current_day, - every_nth_month: 1 + day_of_month: current_day }) partner_group.save partner_group.partners << partner @@ -169,8 +164,7 @@ ) partner_group.reminder_schedule.assign_attributes({ by_month_or_week: "day_of_month", - day_of_month: current_day - 1, - every_nth_month: 1 + day_of_month: current_day - 1 }) partner_group.save partner_group.partners << partner diff --git a/spec/services/reminder_schedule_service_spec.rb b/spec/services/reminder_schedule_service_spec.rb index a5ac562e7e..1875d5c6a3 100644 --- a/spec/services/reminder_schedule_service_spec.rb +++ b/spec/services/reminder_schedule_service_spec.rb @@ -1,14 +1,12 @@ RSpec.describe ReminderScheduleService, type: :service do let(:day_of_month_schedule) { ReminderScheduleService.new({ - every_nth_month: 1, by_month_or_week: "day_of_month", day_of_month: 10 }) } let(:day_of_week_schedule) { ReminderScheduleService.new({ - every_nth_month: 1, by_month_or_week: "day_of_week", day_of_week: 0, every_nth_day: 1 @@ -19,7 +17,6 @@ describe "initialize" do let(:subject) { ReminderScheduleService.new({ - every_nth_month: 1, by_month_or_week: "day_of_month", day_of_month: 10 }) @@ -27,14 +24,8 @@ it "returns a ReminderScheduleService instance" do expect(subject).to be_a_kind_of(ReminderScheduleService) - expect(subject.every_nth_month).to eq 1 expect(subject.day_of_month).to eq 10 end - - it "assigns a default start_date if none is provided" do - expect(subject.start_date).not_to be_nil - expect(subject.start_date).to be_within(1.second).of Time.zone.now - end end describe "from_ical" do @@ -46,7 +37,6 @@ it "returns a ReminderScheduleService instance" do expect(subject).to be_a_kind_of(ReminderScheduleService) - expect(subject.every_nth_month).to eq 1 expect(subject.day_of_month).to eq 10 expect(subject.start_date).to be_within(1.second).of Time.zone.local(2020, 10, 10) end @@ -65,13 +55,11 @@ describe "assign_attributes" do it "updates the ReminderScheduleService's attributes", :aggregate_failures do empty_schedule.assign_attributes({ - every_nth_month: 1, by_month_or_week: "day_of_week", day_of_week: 0, every_nth_day: 1 }) - expect(empty_schedule.every_nth_month).to eq 1 expect(empty_schedule.by_month_or_week).to eq "day_of_week" expect(empty_schedule.day_of_week).to eq 0 expect(empty_schedule.every_nth_day).to eq 1 @@ -120,12 +108,12 @@ end describe "no_fields_filled_out?" do - it "returns true if all fields, except start_date, are nil" do + it "returns true if all fields are nil" do expect(empty_schedule.no_fields_filled_out?).to be true end it "returns false otherwise", :aggregate_failures do - empty_schedule.every_nth_month = 1 + empty_schedule.by_month_or_week = "day_of_month" expect(empty_schedule.no_fields_filled_out?).to be false expect(day_of_month_schedule.no_fields_filled_out?).to be false expect(day_of_week_schedule.no_fields_filled_out?).to be false @@ -175,30 +163,6 @@ end describe "validations" do - it "validates every_nth_month falls within range", :aggregate_failures do - (1..12).step(1) do |n| - day_of_month_schedule.every_nth_month = n - expect(day_of_month_schedule).to be_valid - end - day_of_month_schedule.every_nth_month = "1" - expect(day_of_month_schedule).to be_valid - day_of_month_schedule.every_nth_month = -1 - expect(day_of_month_schedule).not_to be_valid - day_of_month_schedule.every_nth_month = "other_string" - expect(day_of_month_schedule).not_to be_valid - day_of_month_schedule.every_nth_month = nil - expect(day_of_month_schedule).not_to be_valid - end - - it "validates start_date is a valid date or date string", :aggregate_failures do - day_of_month_schedule.start_date = Time.zone.now - expect(day_of_month_schedule).to be_valid - day_of_month_schedule.start_date = "2020/10/10" - expect(day_of_month_schedule).to be_valid - day_of_month_schedule.start_date = nil - expect(day_of_month_schedule).not_to be_valid - end - it "validates by_month_or_week is one of the accepted strings", :aggregate_failures do day_of_month_schedule.by_month_or_week = "other_string" expect(day_of_month_schedule).not_to be_valid diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index bb1e81a9cd..51fdb77c28 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -26,21 +26,6 @@ expect(page).to have_content("Monthly on the 1st Sunday") end - it "can set a monthly frequency for reminders" do - select("Every 3 month", from: "How frequently should reminders be sent (e.g. \"monthly\", \"every 3 months\", etc.)?") - choose "Day of Month" - fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: 1 - fill_in "Default deadline day (final day of month to submit Requests)", with: 10 - click_on save_button - - if post_form_submit - send(post_form_submit) - end - - expect(page).to have_content("Every 3 months on the 1st day of the month") - expect(page).to have_content("10th after the reminder") - end - it "warns the user if they enter the same reminder and deadline day" do choose "Day of Month" fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: 15 @@ -100,22 +85,8 @@ def safe_subtract_days(date, num) it "prior to the current date and start date" do reminder_date = safe_subtract_days(@now, 2) - start_date = safe_subtract_days(@now, 1) - fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: reminder_date.day - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(start_date) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - - start_date = safe_add_days(@now, 1) - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") - schedule = IceCube::Schedule.new(start_date) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) @@ -123,90 +94,14 @@ def safe_subtract_days(date, num) it "after the current date and start date" do reminder_date = safe_add_days(@now, 2) - start_date = safe_subtract_days(@now, 1) fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: reminder_date.day - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(start_date) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - - start_date = safe_add_days(@now, 1) - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") - schedule = IceCube::Schedule.new(start_date) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end - it "after the start date and prior to the current date" do - start_date = safe_subtract_days(@now, 2) - reminder_date = safe_subtract_days(@now, 1) - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: reminder_date.day - expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(start_date) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - - it "after the current date and prior to the start date" do - start_date = safe_add_days(@now, 2) - reminder_date = safe_add_days(@now, 1) - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: reminder_date.day - expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(start_date) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - - it "same as the current date and prior to the start date" do - start_date = safe_add_days(@now, 1) - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: @now.day - expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(start_date) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(@now.day)) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - - it "same as the current date and after the start date" do - start_date = safe_subtract_days(@now, 1) - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: @now.day - expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(start_date) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(@now.day)) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - - it "same as the start date and prior to the current date" do - start_date = safe_subtract_days(@now, 1) - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: start_date.day - expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(start_date) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(start_date.day)) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - - it "same as the start date and after the current date" do - start_date = safe_add_days(@now, 1) - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: start_date.strftime("%Y-%m-%d") - fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: start_date.day - expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(start_date) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(start_date.day)) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - it "same as the start and current date" do - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: @now.strftime("%Y-%m-%d") fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: @now.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now) @@ -235,18 +130,7 @@ def calc_every_nth_day(target_date) select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now - 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") - schedule = IceCube::Schedule.new(@now + 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) @@ -258,110 +142,20 @@ def calc_every_nth_day(target_date) select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now - 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") - schedule = IceCube::Schedule.new(@now + 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: @now.strftime("%Y-%m-%d") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end - it "after the start date and prior to the current date" do - target_date = @now - 1.day - every_nth_day = calc_every_nth_day(target_date) - select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") - select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now - 2.days).strftime("%Y-%m-%d") - expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now - 2.days) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - - it "after the current date and prior to the start date" do - target_date = @now + 1.day - every_nth_day = calc_every_nth_day(target_date) - select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") - select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now + 2.days).strftime("%Y-%m-%d") - expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now + 2.days) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - - it "same as the current date and prior to the start date" do - target_date = @now - every_nth_day = calc_every_nth_day(target_date) - select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") - select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now + 1.day).strftime("%Y-%m-%d") - expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now + 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - - it "same as the current date and after the start date" do - target_date = @now - every_nth_day = calc_every_nth_day(target_date) - select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") - select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: (@now - 1.day).strftime("%Y-%m-%d") - expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(@now - 1.day) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - - it "same as the start date and prior to the current date" do - target_date = @now - 1.day - every_nth_day = calc_every_nth_day(target_date) - select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") - select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: target_date.strftime("%Y-%m-%d") - expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(target_date) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - - it "same as the start date and after the current date" do - target_date = @now + 1.day - every_nth_day = calc_every_nth_day(target_date) - select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") - select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: target_date.strftime("%Y-%m-%d") - expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(target_date) - schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) - expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - it "same as the start and current date" do target_date = @now every_nth_day = calc_every_nth_day(target_date) select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "#{form_prefix}_reminder_schedule_service_start_date", with: target_date.strftime("%Y-%m-%d") expect(page).to have_content("Your next reminder date is") - schedule = IceCube::Schedule.new(target_date) + schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index 24f52f5090..7f9f47813e 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -122,7 +122,6 @@ def post_form_submit it "the deadline day form's reminder and deadline dates are consistent with the dates calculated by the FetchPartnersToRemindNowService and DeadlineService" do choose "Day of Month" - select("Every 2 month", from: "How frequently should reminders be sent (e.g. \"monthly\", \"every 3 months\", etc.)?") fill_in "organization_reminder_schedule_service_day_of_month", with: 14 fill_in "Default deadline day (final day of month to submit Requests)", with: 21 diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index aad739d669..26bcf7be07 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -732,7 +732,6 @@ it "the deadline day form's reminder and deadline dates are consistent with the dates calculated by the FetchPartnersToRemindNowService and DeadlineService" do choose "Day of Month" - select("Every 2 month", from: "How frequently should reminders be sent (e.g. \"monthly\", \"every 3 months\", etc.)?") fill_in "partner_group_reminder_schedule_service_day_of_month", with: 14 fill_in "Default deadline day (final day of month to submit Requests)", with: 21 From b5eaaa9ba05e44534de4edd4ff2eb822bfe44364 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 28 Jul 2025 12:05:42 -0700 Subject: [PATCH 84/94] Changed field label to be consistent with reminder day of week label --- app/views/shared/_deadline_day_fields.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index fcfc508580..92947c5238 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -16,7 +16,7 @@ <%= f.label :by_month_or_week_day_of_week, 'Day of the Week' %>
- <%= f.input :day_of_month, as: :integer, wrapper: :input_group, label: 'Reminder date' do %> + <%= f.input :day_of_month, as: :integer, wrapper: :input_group, label: 'Reminder day of month' do %> <%= f.number_field :day_of_month, min: ReminderScheduleService::MIN_DAY_OF_MONTH, From 16715f787961a1df31c9e26fcb89d995033afad5 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 28 Jul 2025 12:17:44 -0700 Subject: [PATCH 85/94] Forgot to remove unnecessary test now that start_date isn't tracked --- spec/services/reminder_schedule_service_spec.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/services/reminder_schedule_service_spec.rb b/spec/services/reminder_schedule_service_spec.rb index 1875d5c6a3..8141a368fe 100644 --- a/spec/services/reminder_schedule_service_spec.rb +++ b/spec/services/reminder_schedule_service_spec.rb @@ -38,7 +38,6 @@ it "returns a ReminderScheduleService instance" do expect(subject).to be_a_kind_of(ReminderScheduleService) expect(subject.day_of_month).to eq 10 - expect(subject.start_date).to be_within(1.second).of Time.zone.local(2020, 10, 10) end it "returns nil if a blank or invalid ical string is provided", :aggregate_failures do From 07ec680347383ff0e24cd6bdf07e587bd3032301 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 31 Jul 2025 10:29:35 -0700 Subject: [PATCH 86/94] Changed wording of deadline day field --- .../controllers/deadline_day_controller.js | 2 +- app/views/organizations/_details.html.erb | 6 +++--- app/views/partners/_partner_groups_table.html.erb | 2 +- app/views/shared/_deadline_day_fields.html.erb | 2 +- .../bank/getting_started_customization.md | 2 +- docs/user_guide/bank/pm_partner_reminders.md | 2 +- spec/support/deadline_day_fields_shared_example.rb | 14 +++++++------- spec/system/organization_system_spec.rb | 12 ++++++------ spec/system/partner_system_spec.rb | 12 ++++++------ 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/app/javascript/controllers/deadline_day_controller.js b/app/javascript/controllers/deadline_day_controller.js index 11357f54f1..75ee50f6f6 100644 --- a/app/javascript/controllers/deadline_day_controller.js +++ b/app/javascript/controllers/deadline_day_controller.js @@ -94,7 +94,7 @@ export default class extends Controller { $(this.deadlineTextTarget).text("Deadline day must be between 1 and 28"); } else { $(this.deadlineTextTarget).removeClass('text-danger').addClass('text-muted'); - $(this.deadlineTextTarget).text(deadline_date ? `Your next deadline date is ${deadline_date.toDateString()}.` : ""); + $(this.deadlineTextTarget).text(deadline_date ? `The deadline on your next reminder email will be ${deadline_date.toDateString()}.` : ""); } } } diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb index 2609f1a331..5ea9d4989b 100644 --- a/app/views/organizations/_details.html.erb +++ b/app/views/organizations/_details.html.erb @@ -171,7 +171,7 @@

Other emails

-
Default reminder day (day of month an email reminder to submit Requests is sent to Partners)
+
Reminder emails are sent

<%= fa_icon "calendar" %> <%= @organization.reminder_schedule&.show_description.blank? ? 'Not defined' : @organization.reminder_schedule&.show_description %> @@ -183,13 +183,13 @@

-
Default deadline day (final day of month to submit Requests)
+
Deadline day in reminder email

<%= fa_icon "calendar" %> <%= @organization.deadline_day.blank? ? 'Not defined' : "The #{@organization.deadline_day.ordinalize} after the reminder." %> <% if @organization.deadline_day && @organization.reminder_schedule.valid? %> - <%= "Your next deadline date is #{DeadlineService.new( + <%= "The deadline on your next reminder email will be #{DeadlineService.new( deadline_day: @organization.deadline_day, today: @organization.reminder_schedule.next_occurrence).next_deadline&.strftime('%a %b %d %Y')}." %> <% end %> diff --git a/app/views/partners/_partner_groups_table.html.erb b/app/views/partners/_partner_groups_table.html.erb index 9e825530c8..e7d8d38130 100644 --- a/app/views/partners/_partner_groups_table.html.erb +++ b/app/views/partners/_partner_groups_table.html.erb @@ -55,7 +55,7 @@ Deadlines are the <%= pg.deadline_day.ordinalize %> after the reminder. <% if pg.deadline_day && pg.reminder_schedule.valid? %> - <%= "Your next deadline date is #{DeadlineService.new( + <%= "The deadline on your next reminder email will be #{DeadlineService.new( deadline_day: pg.deadline_day, today: pg.reminder_schedule.next_occurrence).next_deadline&.strftime('%a %b %d %Y')}." %> diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb index 92947c5238..fba7326beb 100644 --- a/app/views/shared/_deadline_day_fields.html.erb +++ b/app/views/shared/_deadline_day_fields.html.erb @@ -48,7 +48,7 @@ <% end %> - <%= parent_form.input :deadline_day, wrapper: :input_group, wrapper_html: { min: 0, max: 28 }, label: 'Default deadline day (final day of month to submit Requests)' do %> + <%= parent_form.input :deadline_day, wrapper: :input_group, wrapper_html: { min: 0, max: 28 }, label: 'Deadline day in reminder email' do %> <%= parent_form.number_field :deadline_day, min: ReminderScheduleService::MIN_DAY_OF_MONTH, diff --git a/docs/user_guide/bank/getting_started_customization.md b/docs/user_guide/bank/getting_started_customization.md index 09c6c213bc..a3678dec03 100644 --- a/docs/user_guide/bank/getting_started_customization.md +++ b/docs/user_guide/bank/getting_started_customization.md @@ -158,7 +158,7 @@ if you have any questions about this! [Additional text for reminder email (see below)] -#### Default deadline day (final day of month to submit Requests) +#### Deadline day in reminder email This is the day which will be included in the reminder email message. It is assumed that the deadline day always occurs after the day the reminder is sent, and in cases where the deadline date specified is in the past, the deadline will be set to the next month. diff --git a/docs/user_guide/bank/pm_partner_reminders.md b/docs/user_guide/bank/pm_partner_reminders.md index b9b3744ca1..5786601988 100644 --- a/docs/user_guide/bank/pm_partner_reminders.md +++ b/docs/user_guide/bank/pm_partner_reminders.md @@ -9,7 +9,7 @@ When configuring a non-monthly reminder schedule (every 2 months, every 3 months Be aware that due to how these schedules are checked, it is unlikely that a newly created or updated schedule set to send a reminder the day it is created or update will actually send that reminder. -## Default deadline day (final day of month to submit Requests) +## Deadline day in reminder email This is the day which will be included in the reminder email message. It is assumed that the deadline day always occurs after the day the reminder is sent, and in cases where the deadline date specified is in the past, the deadline will be set to the next month. diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 51fdb77c28..4a47c27898 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -2,7 +2,7 @@ it "can set a reminder on a day of the month" do choose "Day of Month" fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: 1 - fill_in "Default deadline day (final day of month to submit Requests)", with: 10 + fill_in "Deadline day in reminder email", with: 10 click_on save_button if post_form_submit @@ -16,7 +16,7 @@ choose "Day of the Week" select("First", from: "#{form_prefix}_reminder_schedule_service_every_nth_day") select("Sunday", from: "#{form_prefix}_reminder_schedule_service_day_of_week") - fill_in "Default deadline day (final day of month to submit Requests)", with: 10 + fill_in "Deadline day in reminder email", with: 10 click_on save_button if post_form_submit @@ -29,10 +29,10 @@ it "warns the user if they enter the same reminder and deadline day" do choose "Day of Month" fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: 15 - fill_in "Default deadline day (final day of month to submit Requests)", with: 15 + fill_in "Deadline day in reminder email", with: 15 expect(page).to have_content("Reminder day cannot be the same as deadline day.") expect(page).to_not have_content("Your next reminder date is") - expect(page).to_not have_content("Your next deadline date is") + expect(page).to_not have_content("The deadline on your next reminder email will be") end it "warns the user if the reminder day is outside the range of 1 to 28" do @@ -47,11 +47,11 @@ it "warns the user if the deadline day is outside the range of 1 to 28" do choose "Day of Month" - fill_in "Default deadline day (final day of month to submit Requests)", with: "-1" + fill_in "Deadline day in reminder email", with: "-1" expect(page).to have_content("Deadline day must be between 1 and 28") - fill_in "Default deadline day (final day of month to submit Requests)", with: "20" + fill_in "Deadline day in reminder email", with: "20" expect(page).to_not have_content("Deadline day must be between 1 and 28") - fill_in "Default deadline day (final day of month to submit Requests)", with: "100" + fill_in "Deadline day in reminder email", with: "100" expect(page).to have_content("Deadline day must be between 1 and 28") end diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index 7f9f47813e..65116aaba3 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -68,7 +68,7 @@ expect(page).to have_content("Annual Survey") expect(page).not_to have_content("Your next reminder date is ") - expect(page).not_to have_content("Your next deadline date is ") + expect(page).not_to have_content("The deadline on your next reminder email will be ") end context "with a reminder schedule" do @@ -85,14 +85,14 @@ it "reports the next date a reminder email will be sent" do visit organization_path expect(page).to have_content("Your next reminder date is Tue Oct 20 2020.") - expect(page).not_to have_content("Your next deadline date is Sun Oct 25 2020.") + expect(page).not_to have_content("The deadline on your next reminder email will be Sun Oct 25 2020.") end it "reports the deadline date that will be included in the next reminder email" do organization.update(deadline_day: 25) visit organization_path expect(page).to have_content("Your next reminder date is Tue Oct 20 2020.") - expect(page).to have_content("Your next deadline date is Sun Oct 25 2020.") + expect(page).to have_content("The deadline on your next reminder email will be Sun Oct 25 2020.") end end end @@ -123,7 +123,7 @@ def post_form_submit it "the deadline day form's reminder and deadline dates are consistent with the dates calculated by the FetchPartnersToRemindNowService and DeadlineService" do choose "Day of Month" fill_in "organization_reminder_schedule_service_day_of_month", with: 14 - fill_in "Default deadline day (final day of month to submit Requests)", with: 21 + fill_in "Deadline day in reminder email", with: 21 reminder_text = find('small[data-deadline-day-target="reminderText"]').text reminder_text.slice!("Your next reminder date is ") @@ -131,7 +131,7 @@ def post_form_submit shown_recurrence_date = Time.zone.strptime(reminder_text, "%a %b %d %Y") deadline_text = find('small[data-deadline-day-target="deadlineText"]').text - deadline_text.slice!("Your next deadline date is ") + deadline_text.slice!("The deadline on your next reminder email will be ") deadline_text.slice!(".") shown_deadline_date = Time.zone.strptime(deadline_text, "%a %b %d %Y") @@ -146,7 +146,7 @@ def post_form_submit expect(DeadlineService.new(deadline_day: DeadlineService.get_deadline_for_partner(partner)).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date expect(page).to have_content("Your next reminder date is #{reminder_text}.") - expect(page).to have_content("Your next deadline date is #{deadline_text}.") + expect(page).to have_content("The deadline on your next reminder email will be #{deadline_text}.") end it 'can select if the org repackages essentials' do diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 26bcf7be07..2565219ec5 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -632,7 +632,7 @@ expect(page).to have_content("Category One") expect(page).not_to have_content("Your next reminder date is Tue Oct 20 2020.") - expect(page).not_to have_content("Your next deadline date is Sun Oct 25 2020.") + expect(page).not_to have_content("The deadline on your next reminder email will be Sun Oct 25 2020.") end context "with a reminder schedule" do @@ -654,7 +654,7 @@ visit partners_path click_on 'Groups' expect(page).to have_content("Your next reminder date is Tue Oct 20 2020.") - expect(page).to have_content("Your next deadline date is Sun Oct 25 2020.") + expect(page).to have_content("The deadline on your next reminder email will be Sun Oct 25 2020.") end end end @@ -683,7 +683,7 @@ assert page.has_content? 'Test Group' assert page.has_content? item_category_2.name expect(page).to have_content("Your next reminder date is Sun Nov 01 2020.") - expect(page).to have_content("Your next deadline date is Wed Nov 25 2020.") + expect(page).to have_content("The deadline on your next reminder email will be Wed Nov 25 2020.") end end @@ -733,7 +733,7 @@ it "the deadline day form's reminder and deadline dates are consistent with the dates calculated by the FetchPartnersToRemindNowService and DeadlineService" do choose "Day of Month" fill_in "partner_group_reminder_schedule_service_day_of_month", with: 14 - fill_in "Default deadline day (final day of month to submit Requests)", with: 21 + fill_in "Deadline day in reminder email", with: 21 reminder_text = find('small[data-deadline-day-target="reminderText"]').text reminder_text.slice!("Your next reminder date is ") @@ -741,7 +741,7 @@ shown_recurrence_date = Time.zone.strptime(reminder_text, "%a %b %d %Y") deadline_text = find('small[data-deadline-day-target="deadlineText"]').text - deadline_text.slice!("Your next deadline date is ") + deadline_text.slice!("The deadline on your next reminder email will be ") deadline_text.slice!(".") shown_deadline_date = Time.zone.strptime(deadline_text, "%a %b %d %Y") @@ -756,7 +756,7 @@ expect(DeadlineService.new(deadline_day: DeadlineService.get_deadline_for_partner(partner)).next_deadline.in_time_zone(Time.zone)).to be_within(1.second).of shown_deadline_date expect(page).to have_content("Your next reminder date is #{reminder_text}.") - expect(page).to have_content("Your next deadline date is #{deadline_text}.") + expect(page).to have_content("The deadline on your next reminder email will be #{deadline_text}.") end end end From de5ceb2c20e4f91d08c85ed96e50b16cfb432159 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 31 Jul 2025 10:31:04 -0700 Subject: [PATCH 87/94] Fixed deadline day controller incorrectly predicting deadline day if reminder would be sent on the 31st of a month --- .../controllers/deadline_day_controller.js | 4 ++-- spec/support/deadline_day_fields_shared_example.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/javascript/controllers/deadline_day_controller.js b/app/javascript/controllers/deadline_day_controller.js index 75ee50f6f6..1453de09de 100644 --- a/app/javascript/controllers/deadline_day_controller.js +++ b/app/javascript/controllers/deadline_day_controller.js @@ -68,10 +68,10 @@ export default class extends Controller { } if (reminder_date && this.deadlineDayTarget.value) { deadline_date = new Date(reminder_date.getTime()); - if( deadline_date.getDate() >= parseInt(this.deadlineDayTarget.value)){ + deadline_date.setDate(parseInt(this.deadlineDayTarget.value)) + if( reminder_date.getDate() >= parseInt(this.deadlineDayTarget.value)){ deadline_date.setMonth( deadline_date.getMonth() + 1 ) } - deadline_date.setDate(parseInt(this.deadlineDayTarget.value)) } if (this.byDayOfMonthTarget.checked && this.dayOfMonthTarget.value diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 4a47c27898..501cd6043c 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -159,6 +159,18 @@ def calc_every_nth_day(target_date) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) end + + it "at the very end of the month" do + target_date = @now.end_of_month + every_nth_day = calc_every_nth_day(target_date) + select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") + select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") + + expect(page).to have_content("Your next reminder date is") + schedule = IceCube::Schedule.new(@now) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) + expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) + end end end end From 1f4fa0de3572b01e3c907ad88545e79e79b668bb Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 31 Jul 2025 11:07:47 -0700 Subject: [PATCH 88/94] Updated user guide to reflect reduced scope of reminder schedule form --- docs/user_guide/bank/getting_started_customization.md | 2 +- docs/user_guide/bank/pm_partner_reminders.md | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/user_guide/bank/getting_started_customization.md b/docs/user_guide/bank/getting_started_customization.md index a3678dec03..cfdaf2216f 100644 --- a/docs/user_guide/bank/getting_started_customization.md +++ b/docs/user_guide/bank/getting_started_customization.md @@ -132,7 +132,7 @@ This is a special topic that has its own guide page [here](special_custom_units. ## Other emails #### Send reminders on a specific day of the month (e.g. "the 5th") or a day of the week (eg "the first Tuesday")? -You may configure how frequently you would like reminders to be sent to your Partners. +You may configure when you would like reminder emails to be sent to your Partners. This works in conjunction with the reminder configuration set on a partner group level (see [Partner Groups](pm_partner_groups.md)) and the partner specific configuration (see [Adding a single Partner](pm_adding_a_partner.md)). diff --git a/docs/user_guide/bank/pm_partner_reminders.md b/docs/user_guide/bank/pm_partner_reminders.md index 5786601988..93f3a209bc 100644 --- a/docs/user_guide/bank/pm_partner_reminders.md +++ b/docs/user_guide/bank/pm_partner_reminders.md @@ -1,11 +1,9 @@ # Partner Reminder Emails You may configure a reminder schedule on an organization and/or Partner Group level. Partners who are covered by these categories, and who individually have reminders enabled, will receive an email based on the schedule, reminding them of the deadline for submitting requests. -You may configure the monthly frequency of reminders, the date on or after which reminders will first be sent (refered to as the start date), the date of the month or weekday of the month they are sent, and the deadline date included in the email. +You may configure the date of the month or weekday of the month the reminders are sent, and the deadline date included in the email. -As you fill out the form, it should show you a preview of the next time the reminder will be sent, and the deadline date that will be included in the email. - -When configuring a non-monthly reminder schedule (every 2 months, every 3 months, etc.) it is recommended you set the start date to correspond to the the first date you would like reminders to be sent. For example, if the reminder is set to be every 3 months on the 14th, and it is currently January 21st, it is recommended to set the start date to one of Febuary 14th, March 14th, April 14th, etc. +As you fill out the form, it should show you a preview of the next date the reminder will be sent, and the deadline date that will be included in that email. Be aware that due to how these schedules are checked, it is unlikely that a newly created or updated schedule set to send a reminder the day it is created or update will actually send that reminder. From 0e9e8a167d71e0d889d057dc2493c329d072511b Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 4 Aug 2025 09:43:50 -0700 Subject: [PATCH 89/94] Fixed deadline_day being checked against day_of_month even for by_day_of_week schedules, added tests to verify --- app/models/organization.rb | 2 +- app/models/partner_group.rb | 2 +- spec/models/organization_spec.rb | 17 +++++++++++++++++ spec/models/partner_group_spec.rb | 16 ++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/models/organization.rb b/app/models/organization.rb index 68c71d841e..0247ba00d8 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -271,7 +271,7 @@ def reminder_schedule_is_empty_or_valid? end def deadline_not_on_reminder_date? - if reminder_schedule.day_of_month.to_i == deadline_day.to_i + if reminder_schedule.by_month_or_week == "day_of_month" && reminder_schedule.day_of_month.to_i == deadline_day.to_i errors.add(:day_of_month, "Reminder day must not be the same as deadline day") false end diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index 245f3da4fe..dbe4c4d3e3 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -62,7 +62,7 @@ def reminder_schedule_present? end def deadline_not_on_reminder_date? - if reminder_schedule.day_of_month.to_i == deadline_day.to_i + if reminder_schedule.by_month_or_week == "day_of_month" && reminder_schedule.day_of_month.to_i == deadline_day.to_i errors.add(:day_of_month, "Reminder day must not be the same as deadline day") false end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 2eebd0c2bb..1b81b2e57a 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -79,6 +79,23 @@ expect(organization).to be_valid end + + it "validates deadline_day and reminder date are different for day of month reminders" do + organization.update(deadline_day: 10) + organization.reminder_schedule.assign_attributes(by_month_or_week: "day_of_month", day_of_month: 10) + expect(organization).to_not be_valid + organization.reminder_schedule.assign_attributes(day_of_month: 11) + expect(organization).to be_valid + end + + it "does not validate deadline_day and reminder date are different for day of week reminders" do + # Deadline_day and day_of_month both aren't set and so are the same + organization.reminder_schedule.assign_attributes(by_month_or_week: "day_of_week", day_of_week: 0, every_nth_day: 1) + expect(organization).to be_valid + organization.update(deadline_day: 10) + organization.reminder_schedule.assign_attributes(day_of_month: 10) + expect(organization).to be_valid + end end context "Associations >" do diff --git a/spec/models/partner_group_spec.rb b/spec/models/partner_group_spec.rb index 793244b14f..cf406529c9 100644 --- a/spec/models/partner_group_spec.rb +++ b/spec/models/partner_group_spec.rb @@ -70,6 +70,22 @@ expect(build(:partner_group, send_reminders: true, deadline_day: 10, reminder_schedule_definition: valid_reminder_schedule)).to be_valid end end + + it "validates deadline_day and reminder date are different for day of month reminders" do + partner_group = create(:partner_group, name: "Foo") + partner_group.update(deadline_day: 10) + partner_group.reminder_schedule.assign_attributes(by_month_or_week: "day_of_month", day_of_month: 10) + expect(partner_group).to_not be_valid + partner_group.reminder_schedule.assign_attributes(day_of_month: 11) + expect(partner_group).to be_valid + end + + it "does not validate deadline_day and reminder date are different for day of week reminders" do + partner_group = create(:partner_group, name: "Foo") + partner_group.update(deadline_day: 10) + partner_group.reminder_schedule.assign_attributes(by_month_or_week: "day_of_week", day_of_week: 0, every_nth_day: 1, day_of_month: 10) + expect(partner_group).to be_valid + end end describe "versioning" do From d54ce828bbc8dbeaa98a8b8450142b7c1746cc7d Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Sun, 10 Aug 2025 07:20:27 -0700 Subject: [PATCH 90/94] Forgot to add comment --- app/javascript/controllers/deadline_day_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/controllers/deadline_day_controller.js b/app/javascript/controllers/deadline_day_controller.js index 1453de09de..ea3ff2c871 100644 --- a/app/javascript/controllers/deadline_day_controller.js +++ b/app/javascript/controllers/deadline_day_controller.js @@ -39,7 +39,7 @@ export default class extends Controller { let reminder_date = null; let deadline_date = null; - // TODO: Add comment explaining assumptions about monthylInterval and today? + // For now, we are assuming that all schedules are monthly and start on the current date let monthlyInterval = 1; let today = new Date(); let untilDate = new Date( today ); From 3931110dafb05caf9e7a05749dfbe6d4a088cb2a Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 11 Aug 2025 06:45:27 -0700 Subject: [PATCH 91/94] Reworked reminder_schedule_is_empty_or_valid? conditionals to be more readable --- app/models/organization.rb | 15 +++++--------- app/models/partner_group.rb | 20 ++++++++++--------- app/services/reminder_schedule_service.rb | 4 ++-- .../reminder_schedule_service_spec.rb | 14 ++++++------- 4 files changed, 25 insertions(+), 28 deletions(-) diff --git a/app/models/organization.rb b/app/models/organization.rb index 0247ba00d8..347df9fc02 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -263,21 +263,16 @@ def reminder_schedule_is_empty_or_valid? # The schedule shouldn't be validated if the user hasn't touched that form, # so if by_month_or_week is still the default (nil) assume the user didn't # intend to fill out that form and don't validate. - unless reminder_schedule.no_fields_filled_out? || reminder_schedule.by_month_or_week.nil? - unless reminder_schedule.valid? && deadline_not_on_reminder_date? + if reminder_schedule.fields_filled_out? && reminder_schedule.by_month_or_week.present? + if !reminder_schedule.valid? errors.merge!(reminder_schedule.errors) end + if reminder_schedule.by_month_or_week == "day_of_month" && reminder_schedule.day_of_month.to_i == deadline_day.to_i + errors.add(:day_of_month, "Reminder day must not be the same as deadline day") + end end end - def deadline_not_on_reminder_date? - if reminder_schedule.by_month_or_week == "day_of_month" && reminder_schedule.day_of_month.to_i == deadline_day.to_i - errors.add(:day_of_month, "Reminder day must not be the same as deadline day") - false - end - true - end - private def correct_logo_mime_type diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index dbe4c4d3e3..2255eaff60 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -48,24 +48,26 @@ def reminder_schedule_is_empty_or_valid? # The schedule shouldn't be validated if the user hasn't touched that form, # so if by_month_or_week is still the default (nil) assume the user didn't # intend to fill out that form and don't validate. - unless reminder_schedule.no_fields_filled_out? || reminder_schedule.by_month_or_week.nil? - unless reminder_schedule.valid? && deadline_not_on_reminder_date? + if reminder_schedule.fields_filled_out? && reminder_schedule.by_month_or_week.present? + if !reminder_schedule.valid? errors.merge!(reminder_schedule.errors) end + if deadline_on_reminder_date? + errors.add(:day_of_month, "Reminder day must not be the same as deadline day") + end end end def reminder_schedule_present? - unless reminder_schedule.valid? && deadline_not_on_reminder_date? + unless reminder_schedule.valid? && !deadline_on_reminder_date? errors.add(:send_reminders, "Valid reminder schedule must be present if send_reminders is true") end + if deadline_on_reminder_date? + errors.add(:day_of_month, "Reminder day must not be the same as deadline day") + end end - def deadline_not_on_reminder_date? - if reminder_schedule.by_month_or_week == "day_of_month" && reminder_schedule.day_of_month.to_i == deadline_day.to_i - errors.add(:day_of_month, "Reminder day must not be the same as deadline day") - false - end - true + def deadline_on_reminder_date? + reminder_schedule.by_month_or_week == "day_of_month" && reminder_schedule.day_of_month.to_i == deadline_day.to_i end end diff --git a/app/services/reminder_schedule_service.rb b/app/services/reminder_schedule_service.rb index bd91b2355e..0ac9bd7901 100644 --- a/app/services/reminder_schedule_service.rb +++ b/app/services/reminder_schedule_service.rb @@ -84,8 +84,8 @@ def show_description to_icecube_schedule&.recurrence_rules&.first.to_s end - def no_fields_filled_out? - by_month_or_week.nil? && day_of_month.nil? && day_of_week.nil? && every_nth_day.nil? + def fields_filled_out? + by_month_or_week.present? || day_of_month.present? || day_of_week.present? || every_nth_day.present? end def occurs_on?(date) diff --git a/spec/services/reminder_schedule_service_spec.rb b/spec/services/reminder_schedule_service_spec.rb index 8141a368fe..228a624d07 100644 --- a/spec/services/reminder_schedule_service_spec.rb +++ b/spec/services/reminder_schedule_service_spec.rb @@ -106,16 +106,16 @@ end end - describe "no_fields_filled_out?" do - it "returns true if all fields are nil" do - expect(empty_schedule.no_fields_filled_out?).to be true + describe "fields_filled_out?" do + it "returns false if all fields are nil" do + expect(empty_schedule.fields_filled_out?).to be false end - it "returns false otherwise", :aggregate_failures do + it "returns true otherwise", :aggregate_failures do empty_schedule.by_month_or_week = "day_of_month" - expect(empty_schedule.no_fields_filled_out?).to be false - expect(day_of_month_schedule.no_fields_filled_out?).to be false - expect(day_of_week_schedule.no_fields_filled_out?).to be false + expect(empty_schedule.fields_filled_out?).to be true + expect(day_of_month_schedule.fields_filled_out?).to be true + expect(day_of_week_schedule.fields_filled_out?).to be true end end From 1b182567bbef71a2a7d82fd670eeec781875c3d9 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Mon, 11 Aug 2025 07:11:56 -0700 Subject: [PATCH 92/94] Combined multiple asserts into larger it blocks for the sake of performance --- .../deadline_day_fields_shared_example.rb | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 501cd6043c..9b2f400d7b 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -26,36 +26,32 @@ expect(page).to have_content("Monthly on the 1st Sunday") end - it "warns the user if they enter the same reminder and deadline day" do + it "warns the user if they enter an invalid reminder or deadline day", :aggregate_failures do choose "Day of Month" fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: 15 fill_in "Deadline day in reminder email", with: 15 expect(page).to have_content("Reminder day cannot be the same as deadline day.") expect(page).to_not have_content("Your next reminder date is") expect(page).to_not have_content("The deadline on your next reminder email will be") - end - it "warns the user if the reminder day is outside the range of 1 to 28" do - choose "Day of Month" + fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: "-1" expect(page).to have_content("Reminder day must be between 1 and 28") fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: "20" expect(page).to_not have_content("Reminder day must be between 1 and 28") fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: "100" expect(page).to have_content("Reminder day must be between 1 and 28") - end - - it "warns the user if the deadline day is outside the range of 1 to 28" do - choose "Day of Month" fill_in "Deadline day in reminder email", with: "-1" expect(page).to have_content("Deadline day must be between 1 and 28") fill_in "Deadline day in reminder email", with: "20" expect(page).to_not have_content("Deadline day must be between 1 and 28") fill_in "Deadline day in reminder email", with: "100" + expect(page).to have_content("Reminder day cannot be the same as deadline day.") + fill_in "Deadline day in reminder email", with: "101" expect(page).to have_content("Deadline day must be between 1 and 28") end - describe "calculates the reminder and deadline dates" do + describe "reported reminder and deadline dates" do # The reminder day (the #{form_prefix}_reminder_schedule_service_day_of_month field ) has to be less than or equal to 28. # These functions are implemented to calculate dates prior or after @now that do not fall on a # date with a day greater than 28. @@ -83,25 +79,24 @@ def safe_subtract_days(date, num) @now = safe_add_days(Time.zone.now, 0) end - it "prior to the current date and start date" do + it "calculates the reminder and deadline dates", :aggregate_failures do + # Prior reminder_date = safe_subtract_days(@now, 2) fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: reminder_date.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - it "after the current date and start date" do + # After reminder_date = safe_add_days(@now, 2) fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: reminder_date.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_month(reminder_date.day)) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - it "same as the start and current date" do + # Same fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: @now.day expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now) @@ -124,48 +119,42 @@ def calc_every_nth_day(target_date) every_nth_day end - it "prior to the current date and start date" do + it "calculates the reminder and deadline dates", :aggregate_failures do + # Prior target_date = @now - 2.days every_nth_day = calc_every_nth_day(target_date) select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - it "after the current date and start date" do + # After target_date = @now + 2.days every_nth_day = calc_every_nth_day(target_date) select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - it "same as the start and current date" do + # Same target_date = @now every_nth_day = calc_every_nth_day(target_date) select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) expect(page).to have_content(schedule.next_occurrence.strftime("%b %d %Y")) - end - it "at the very end of the month" do + # End of the month target_date = @now.end_of_month every_nth_day = calc_every_nth_day(target_date) select(ReminderScheduleService::NTH_TO_WORD_MAP[every_nth_day], from: "#{form_prefix}_reminder_schedule_service_every_nth_day") select(ReminderScheduleService::DAY_OF_WEEK_COLLECTION[target_date.wday][0], from: "#{form_prefix}_reminder_schedule_service_day_of_week") - expect(page).to have_content("Your next reminder date is") schedule = IceCube::Schedule.new(@now) schedule.add_recurrence_rule(IceCube::Rule.monthly.day_of_week(target_date.wday => [every_nth_day])) From 83f0ad7840011c477965fd2cc666f3b375b28c04 Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 14 Aug 2025 09:51:25 -0700 Subject: [PATCH 93/94] Moved tests that didn't rely on javascript to requests --- spec/requests/organization_requests_spec.rb | 27 ++++++++++++ spec/requests/partners_requests_spec.rb | 9 +++- spec/system/organization_system_spec.rb | 47 --------------------- spec/system/partner_system_spec.rb | 19 ++------- 4 files changed, 38 insertions(+), 64 deletions(-) diff --git a/spec/requests/organization_requests_spec.rb b/spec/requests/organization_requests_spec.rb index b0e294d3b0..c025c88d92 100644 --- a/spec/requests/organization_requests_spec.rb +++ b/spec/requests/organization_requests_spec.rb @@ -123,6 +123,8 @@ expect(html.text).to include("Logo") expect(html.text).to include("Use one-step Partner invite and approve process?") expect(html.text).to include("Receive email when Partner makes a Request?") + expect(html.text).not_to include("Your next reminder date is ") + expect(html.text).not_to include("The deadline on your next reminder email will be ") end it "displays the correct organization details" do @@ -153,6 +155,31 @@ end end + context "with a reminder schedule" do + before do + travel_to Time.zone.local(2020, 10, 10) + valid_reminder_schedule = ReminderScheduleService.new({ + by_month_or_week: "day_of_month", + every_nth_month: 1, + day_of_month: 20 + }).to_ical + organization.update(reminder_schedule_definition: valid_reminder_schedule) + end + + it "reports the next date a reminder email will be sent" do + get organization_path + expect(response.body).to include "Your next reminder date is Tue Oct 20 2020." + expect(response.body).not_to include "The deadline on your next reminder email will be Sun Oct 25 2020." + end + + it "reports the deadline date that will be included in the next reminder email" do + organization.update(deadline_day: 25) + get organization_path + expect(response.body).to include "Your next reminder date is Tue Oct 20 2020." + expect(response.body).to include "The deadline on your next reminder email will be Sun Oct 25 2020." + end + end + it "cannot see 'Demote to User' button for admins" do expect(response.body).to_not include "Demote to User" end diff --git a/spec/requests/partners_requests_spec.rb b/spec/requests/partners_requests_spec.rb index 74ff3578f9..2e4255164f 100644 --- a/spec/requests/partners_requests_spec.rb +++ b/spec/requests/partners_requests_spec.rb @@ -16,9 +16,14 @@ context "html" do let(:response_format) { 'html' } - let!(:partner) { create(:partner, organization: organization) } + let!(:partner) { create(:partner, organization: organization, name: "Partner One") } - it { is_expected.to be_successful } + it { + is_expected.to be_successful + expect(response.body).to include "Partner One" + expect(response.body).not_to include "Your next reminder date is Tue Oct 20 2020." + expect(response.body).not_to include "The deadline on your next reminder email will be Sun Oct 25 2020." + } include_examples "restricts access to organization users/admins" end diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index 65116aaba3..90ec161a8a 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -50,53 +50,6 @@ sign_in(organization_admin) end - describe "Viewing the organization" do - it "can view organization details", :aggregate_failures do - organization.update!(one_step_partner_invite: true) - - visit organization_path - - expect(page.find("h1")).to have_text(organization.name) - expect(page).to have_link("Home", href: dashboard_path) - - expect(page).to have_content("Basic information") - expect(page).to have_content("Storage") - expect(page).to have_content("Partner approval process") - expect(page).to have_content("What kind of Requests can approved Partners make?") - expect(page).to have_content("Other emails") - expect(page).to have_content("Printing") - expect(page).to have_content("Annual Survey") - - expect(page).not_to have_content("Your next reminder date is ") - expect(page).not_to have_content("The deadline on your next reminder email will be ") - end - - context "with a reminder schedule" do - before do - travel_to Time.zone.local(2020, 10, 10) - valid_reminder_schedule = ReminderScheduleService.new({ - by_month_or_week: "day_of_month", - every_nth_month: 1, - day_of_month: 20 - }).to_ical - organization.update(reminder_schedule_definition: valid_reminder_schedule) - end - - it "reports the next date a reminder email will be sent" do - visit organization_path - expect(page).to have_content("Your next reminder date is Tue Oct 20 2020.") - expect(page).not_to have_content("The deadline on your next reminder email will be Sun Oct 25 2020.") - end - - it "reports the deadline date that will be included in the next reminder email" do - organization.update(deadline_day: 25) - visit organization_path - expect(page).to have_content("Your next reminder date is Tue Oct 20 2020.") - expect(page).to have_content("The deadline on your next reminder email will be Sun Oct 25 2020.") - end - end - end - describe "Editing the organization" do let(:partner) { create(:partner, organization: organization) } diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 2565219ec5..cd75d347ed 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -612,29 +612,18 @@ sign_in(user) end - let!(:item_category_1) { create(:item_category, name: "Category One", organization: organization) } - let!(:item_category_2) { create(:item_category, name: "Category Two", organization: organization) } + let!(:item_category_1) { create(:item_category, organization: organization) } + let!(:item_category_2) { create(:item_category, organization: organization) } let!(:items_in_category_1) { create_list(:item, 3, item_category_id: item_category_1.id) } let!(:items_in_category_2) { create_list(:item, 3, item_category_id: item_category_2.id) } describe 'viewing the partner groups' do - let!(:partner_group_1) { create(:partner_group, name: "Group One", organization: organization) } - let!(:partner_1) { create(:partner, name: "Partner One", partner_group: partner_group_1) } + let!(:partner_group_1) { create(:partner_group, organization: organization) } + let!(:partner_1) { create(:partner, partner_group: partner_group_1) } before do partner_group_1.item_categories << item_category_1 end - it "shows the name, member partners, and item categories" do - visit partners_path - click_on 'Groups' - expect(page).to have_content("Group One") - expect(page).to have_content("Partner One") - expect(page).to have_content("Category One") - - expect(page).not_to have_content("Your next reminder date is Tue Oct 20 2020.") - expect(page).not_to have_content("The deadline on your next reminder email will be Sun Oct 25 2020.") - end - context "with a reminder schedule" do before do travel_to Time.zone.local(2020, 10, 10) From f12ad32830249e20785a3c7093074aa374c4f2ff Mon Sep 17 00:00:00 2001 From: Benjamin-Couey Date: Thu, 14 Aug 2025 11:49:23 -0700 Subject: [PATCH 94/94] Moved definition of safe_add/subtract_days so they can be used in other tests, used them to fix test failing intermitently on the 14th --- app/models/organization.rb | 2 +- app/models/partner_group.rb | 4 +- .../deadline_day_fields_shared_example.rb | 46 ++++++++++--------- spec/system/organization_system_spec.rb | 4 +- spec/system/partner_system_spec.rb | 4 +- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/app/models/organization.rb b/app/models/organization.rb index 347df9fc02..2a936b7f45 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -269,7 +269,7 @@ def reminder_schedule_is_empty_or_valid? end if reminder_schedule.by_month_or_week == "day_of_month" && reminder_schedule.day_of_month.to_i == deadline_day.to_i errors.add(:day_of_month, "Reminder day must not be the same as deadline day") - end + end end end diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb index 2255eaff60..f3c87d0031 100644 --- a/app/models/partner_group.rb +++ b/app/models/partner_group.rb @@ -54,7 +54,7 @@ def reminder_schedule_is_empty_or_valid? end if deadline_on_reminder_date? errors.add(:day_of_month, "Reminder day must not be the same as deadline day") - end + end end end @@ -64,7 +64,7 @@ def reminder_schedule_present? end if deadline_on_reminder_date? errors.add(:day_of_month, "Reminder day must not be the same as deadline day") - end + end end def deadline_on_reminder_date? diff --git a/spec/support/deadline_day_fields_shared_example.rb b/spec/support/deadline_day_fields_shared_example.rb index 9b2f400d7b..48843e3892 100644 --- a/spec/support/deadline_day_fields_shared_example.rb +++ b/spec/support/deadline_day_fields_shared_example.rb @@ -1,3 +1,27 @@ +# The reminder day (the #{form_prefix}_reminder_schedule_service_day_of_month field ) has to be less than or equal to 28. +# These functions are implemented to calculate dates prior or after a given date that do not fall on a +# date with a day greater than 28. +# It is recommended to use these functions to calculate, from now, inputs for the reminder day and deadline day fields if +# your test cares about the text created by the deadline_day_controller.js controller as there isn't an easy way to spoof +# the current time in the test browser and different behavior could occur if the test is run on different days. +def safe_add_days(date, num) + result = date + num.days + if result.day > 28 + result = result.change({day: 1 + num}) + result += 1.month + end + result +end + +def safe_subtract_days(date, num) + result = date - num.days + if result.day > 28 + result = result.change({day: 28 - num}) + result -= 1.month + end + result +end + RSpec.shared_examples_for "deadline and reminder form" do |form_prefix, save_button, post_form_submit| it "can set a reminder on a day of the month" do choose "Day of Month" @@ -34,7 +58,6 @@ expect(page).to_not have_content("Your next reminder date is") expect(page).to_not have_content("The deadline on your next reminder email will be") - fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: "-1" expect(page).to have_content("Reminder day must be between 1 and 28") fill_in "#{form_prefix}_reminder_schedule_service_day_of_month", with: "20" @@ -52,27 +75,6 @@ end describe "reported reminder and deadline dates" do - # The reminder day (the #{form_prefix}_reminder_schedule_service_day_of_month field ) has to be less than or equal to 28. - # These functions are implemented to calculate dates prior or after @now that do not fall on a - # date with a day greater than 28. - def safe_add_days(date, num) - result = date + num.days - if result.day > 28 - result = result.change({day: 1 + num}) - result += 1.month - end - result - end - - def safe_subtract_days(date, num) - result = date - num.days - if result.day > 28 - result = result.change({day: 28 - num}) - result -= 1.month - end - result - end - context "when the reminder is a day of the month" do before do choose "Day of Month" diff --git a/spec/system/organization_system_spec.rb b/spec/system/organization_system_spec.rb index 90ec161a8a..c232fa1aba 100644 --- a/spec/system/organization_system_spec.rb +++ b/spec/system/organization_system_spec.rb @@ -75,8 +75,8 @@ def post_form_submit it "the deadline day form's reminder and deadline dates are consistent with the dates calculated by the FetchPartnersToRemindNowService and DeadlineService" do choose "Day of Month" - fill_in "organization_reminder_schedule_service_day_of_month", with: 14 - fill_in "Deadline day in reminder email", with: 21 + fill_in "organization_reminder_schedule_service_day_of_month", with: safe_add_days(Time.zone.now, 1).day + fill_in "Deadline day in reminder email", with: safe_add_days(Time.zone.now, 2).day reminder_text = find('small[data-deadline-day-target="reminderText"]').text reminder_text.slice!("Your next reminder date is ") diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index cd75d347ed..8f8af992c0 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -721,8 +721,8 @@ it "the deadline day form's reminder and deadline dates are consistent with the dates calculated by the FetchPartnersToRemindNowService and DeadlineService" do choose "Day of Month" - fill_in "partner_group_reminder_schedule_service_day_of_month", with: 14 - fill_in "Deadline day in reminder email", with: 21 + fill_in "partner_group_reminder_schedule_service_day_of_month", with: safe_add_days(Time.zone.now, 1).day + fill_in "Deadline day in reminder email", with: safe_add_days(Time.zone.now, 2).day reminder_text = find('small[data-deadline-day-target="reminderText"]').text reminder_text.slice!("Your next reminder date is ")