diff --git a/Gemfile b/Gemfile index 770b345a..d4411505 100644 --- a/Gemfile +++ b/Gemfile @@ -97,6 +97,7 @@ end group :test do + gem 'byebug' gem 'rspec', '~> 3.0' gem 'rspec-rails' gem 'rspec-activemodel-mocks' diff --git a/Gemfile.lock b/Gemfile.lock index 7516e7b8..888e7cd5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -589,6 +589,3 @@ DEPENDENCIES wkhtmltopdf-binary! wkhtmltopdf-heroku! zonebie - -BUNDLED WITH - 1.11.2 diff --git a/INSTALL.md b/INSTALL.md index a08ec0e8..17c2d658 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -16,13 +16,8 @@ $ curl -fsSL https://gist.github.com/mislav/055441129184a1512bb5.txt | rbenv install --patch 2.1.2 -3. Open seeds.rb and edit the username and password in the last code - block of that file. The last few lines of seeds.rb create an example - / test user. You're welcome to use those creds locally, but will - definitely want to change them for any production use. Be sure not - to store real creds in seeds.rb! -4. Run `./bin/setup` in the project directory +3. Run `./bin/setup` in the project directory If you see an error about `capybara-webkit`, check your version of `qmake`. You may want to follow the suggestion @@ -39,7 +34,7 @@ successfully. -5. The setup script should create your db, but if it fails for +4. The setup script should create your db, but if it fails for permission reasons, try the following, with your system username in place of `myuser`: @@ -58,7 +53,7 @@ postgres=# GRANT ALL ON DATABASE "arcdata-dev" TO myuser; postgres=# \q ``` -6. Try ./bin/setup again. If you get an error like: +5. Try ./bin/setup again. If you get an error like: ``` ActiveRecord::UnknownAttributeError: unknown attribute: password @@ -78,9 +73,9 @@ $ ./bin/setup ``` -7. Run `./bin/setup` again. Once it runs without errors, your app is set up. +6. Run `./bin/setup` again. Once it runs without errors, your app is set up. -8. Run `bundle exec unicorn -c unicorn.rb` to start the development +7. Run `bundle exec unicorn -c unicorn.rb` to start the development server and visit [http://0.0.0.0:8080](http://0.0.0.0:8080) in your browser. Login with the credentials you created in seeds.rb. The defaults are `admin` and `test123`. @@ -96,4 +91,4 @@ Twilio as part of testing. Go to [Twilio](https://www.twilio.com/try-twilio) to sign up for a trial account and verify your phone number. With a trial account, you'll only -be able to text yourself. \ No newline at end of file +be able to text yourself. diff --git a/app/assets/javascripts/scheduler/calendar.js.coffee b/app/assets/javascripts/scheduler/calendar.js.coffee index 27519475..e3330ec3 100644 --- a/app/assets/javascripts/scheduler/calendar.js.coffee +++ b/app/assets/javascripts/scheduler/calendar.js.coffee @@ -26,14 +26,18 @@ class window.CalendarController complete: (xhr, status) => this.reloadDate(date, period) - new PersonTypeaheadController $('#select-person'), ((id, record) => @params.person_id = id; this.reload()), 'select-person', + new PersonTypeaheadController $('#select-person'), ((id, record) => @params.person_id = id; this.reload()), 'select-person', active: false has_position: true $(document).on 'click', '#highlighting-group > button', (evt) => active = if ($(evt.target).hasClass('active')) then false else true $(evt.target).toggleClass('active', active) - $('.calendar-container').toggleClass($(evt.target).data('style'), active) + style = $(evt.target).data('style') + if style == 'highlight-recommended-shifts' + this.highlightRecommendedShifts(active) + else + $('.calendar-container').toggleClass($(evt.target).data('style'), active) $(document).on 'click', '#select-shift-group > button', (evt) => $('#select-shift-group > button').removeClass('active') @@ -60,6 +64,22 @@ class window.CalendarController val[val.length] = $(el).val() val + highlightRecommendedShifts: (toggleIndicator) -> + all_shift_scores = $('.shift[data-recommendation]') + + sorted_shift_scores = all_shift_scores.sort (first_shift, second_shift) -> + first_score = parseInt($(first_shift).attr('data-recommendation')); + second_score = parseInt($(second_shift).attr('data-recommendation')); + + if(second_score < first_score) + return -1; + else if(second_score > first_score) + return 1; + else + return 0; + + sorted_shift_scores.slice(0,5).toggleClass("recommended", toggleIndicator); + renderArgs: () -> @params @@ -97,5 +117,5 @@ class window.CalendarController $('tbody[data-week=' + date + ']').html(data) when 'monthly' $('.month').html(data) - else - $('.day[data-day=' + date + ']').html(data) \ No newline at end of file + else + $('.day[data-day=' + date + ']').html(data) diff --git a/app/assets/stylesheets/scheduler/calendar.css.scss b/app/assets/stylesheets/scheduler/calendar.css.scss index 4d89d047..3c6f3785 100644 --- a/app/assets/stylesheets/scheduler/calendar.css.scss +++ b/app/assets/stylesheets/scheduler/calendar.css.scss @@ -9,7 +9,7 @@ body.calendar { table.calendar { &.loading { opacity: 0.5; - + } width: 100%; td.day, td.week, td.month[colspan="1"] { @@ -23,7 +23,7 @@ body.calendar { width: 14.2%; } td.week { - + } td label { font-size: inherit; @@ -48,6 +48,10 @@ body.calendar { } } + .hightlight-recommended-shifts { + + } + @mixin rotate($rot) { -webkit-transform: rotate($rot); -o-transform: rotate($rot); @@ -81,11 +85,12 @@ body.calendar { border-bottom-style: none; } } - th.date-header { + div.date-header, th.date-header { font-size: 130%; amax-width: 1em; padding: 0; text-align: center; + font-weight: bold; } tr.end-group { td,th { @@ -99,6 +104,27 @@ body.calendar { afont-size: 125%; line-height: 1.2; } + + .shift { + border-bottom: 1px dashed black; + + div.shift-header-normal { + text-align: center; + font-size: 110%; + font-weight: bold; + } + } + + .shift:last-child { + border-bottom: none; + } + + + .recommended { + border: 1px solid #fdad7c; + background-color: #fedcc7; + } + } .calendar-spreadsheet { @@ -168,7 +194,7 @@ body.calendar { //border-bottom: 1px solid rgb(221,221,221); } } - + } thead { @@ -279,4 +305,4 @@ body.steven { } - \ No newline at end of file + diff --git a/app/helpers/scheduler/calendar_helper.rb b/app/helpers/scheduler/calendar_helper.rb index 6e2d026f..334f5777 100644 --- a/app/helpers/scheduler/calendar_helper.rb +++ b/app/helpers/scheduler/calendar_helper.rb @@ -16,6 +16,10 @@ def render_shifts group, shifts, date, editable end end + def retrieve_recommendation_score(group, shifts, date) + Scheduler::ShiftRecommendationScore.get_score(shifts.first, group, date) + end + def group_to_period group case group.period when 'daily' then 'day' @@ -71,10 +75,10 @@ def render_shift_assignment_info(editable, person, shift, shift_group, my_shifts s << check_box_tag(shift.id.to_s, # Name date.to_s, # Value is_signed_up, # Checked? - id: cbid, + id: cbid, class: 'shift-checkbox', data: { - :assignment => this_assignment.try(:id), + :assignment => this_assignment.try(:id), :period => period, params: create_params(person, shift, shift_group, date) }) << " " diff --git a/app/models/roster/person.rb b/app/models/roster/person.rb index 51f030a7..e6b61e8c 100644 --- a/app/models/roster/person.rb +++ b/app/models/roster/person.rb @@ -3,6 +3,9 @@ class Roster::Person < ActiveRecord::Base include Mappable PHONE_TYPES = [:cell_phone, :home_phone, :work_phone, :alternate_phone, :sms_phone] + NEWNESS_LOW = 0.5 + NEWNESS_MED = 0.7 + NEWNESS_HIGH = 1.0 belongs_to :chapter, class_name: 'Roster::Chapter' belongs_to :primary_county, class_name: 'Roster::County' @@ -20,7 +23,7 @@ class Roster::Person < ActiveRecord::Base belongs_to :alternate_phone_carrier, class_name: 'Roster::CellCarrier' belongs_to :sms_phone_carrier, class_name: 'Roster::CellCarrier' - scope :name_contains, lambda {|query| + scope :name_contains, lambda {|query| where{lower(first_name.op('||', ' ').op('||', last_name)).like("%#{query.downcase}%")} } @@ -193,4 +196,11 @@ def profile_complete? def is_active? vc_is_active or has_role 'always_active' end + + def newness_factor + opportunities = Incidents::ResponderAssignment.for_person(self).count + return NEWNESS_HIGH if opportunities < 5 + return NEWNESS_MED if opportunities >= 5 && opportunities < 20 + NEWNESS_LOW + end end diff --git a/app/models/scheduler/shift.rb b/app/models/scheduler/shift.rb index a1a49a10..9fc86d1b 100644 --- a/app/models/scheduler/shift.rb +++ b/app/models/scheduler/shift.rb @@ -182,4 +182,5 @@ def total_shifts_for_month(month) def display_name "#{shift_groups.first.try :chapter_id} - #{county.try(:abbrev)} #{name} - #{shift_groups.map(&:name).join ', '}" end + end diff --git a/app/models/scheduler/shift_recommendation_score.rb b/app/models/scheduler/shift_recommendation_score.rb new file mode 100644 index 00000000..4eeb93e4 --- /dev/null +++ b/app/models/scheduler/shift_recommendation_score.rb @@ -0,0 +1,47 @@ +class Scheduler::ShiftRecommendationScore + + def self.get_score(shift, shift_group, date) + rand(5).to_i + end + + def self.expected_number_of_incidents(unique_shift) + matching_incidents = Incidents::Incident + .where(:chapter => unique_shift[:chapter]) + .where(:created_at => 12.months.ago..Time.now) + .where("EXTRACT(DOW from date) = :day", {day: unique_shift[:day]}) + .where("EXTRACT(HOUR from created_at) BETWEEN :start AND :end", {start: unique_shift[:start_time], end: unique_shift[:end_time]}).count + + return matching_incidents/52 + end + + def self.unique_shift(shift, shift_group, day) + generate_unique_shift = { day: day, + start_time: shift_group.start_offset/(60*60), + end_time: shift_group.end_offset/(60*60), + chapter: shift.chapter.name } + return generate_unique_shift + end + + def self.shift_response_rate(shift, shift_group, date) + total_calls = total_calls(shift,shift_group,date) + return 0 if total_calls.zero? + calls_with_positive_response(shift,shift_group,date)/total_calls + end + + private + + def self.calls_with_positive_response(shift, shift_group, date) + Incidents::ResponderAssignment. + for_chapter(shift.chapter). + was_available. + #need to match day/time + count.to_f + end + + def self.total_calls(shift, shift_group, date) + Incidents::ResponderAssignment. + for_chapter(shift.chapter). + #need to match day/time + count.to_f + end +end diff --git a/app/views/scheduler/calendar/_day.html.haml b/app/views/scheduler/calendar/_day.html.haml index 18ec162c..d8157d61 100644 --- a/app/views/scheduler/calendar/_day.html.haml +++ b/app/views/scheduler/calendar/_day.html.haml @@ -1,6 +1,6 @@ -%table.shifts - %tr - %th.date-header(colspan=2)=date.day +%div.shifts + %div + %div.date-header(colspan=2)=date.day - calendar.daily_groups.each_with_index do |(group, shifts), group_idx| - assignments = calendar.assignments_for_group_on_day group, date -# cache [group, shifts, assignments, date, ajax_params] do @@ -11,13 +11,12 @@ -if person - my_shifts = calendar.my_shifts_for_group_on_day(group.id, date) - group_class = my_shifts.present? ? 'my-shift' : '' - %tr - %th.shift-header-normal.shift-side{class: [group_class, is_last_group && 'last-group'], rowspan: shifts.count+1}=group.name - %th.shift-header-normal.shift-top{class: group_class}=group.name - - - render_shifts group, shifts, date, editable do |idx, is_first, is_last, needs_signups, row_html| - %tr{class: [group_class, (!is_last_group && is_last && 'end-group')]} - %td{class: needs_signups&&'open'}=row_html + %div.shift{"data-recommendation" => retrieve_recommendation_score(group, shifts, date) } + %div.shift-header-normal.shift-top{class: group_class}=group.name + + - render_shifts group, shifts, date, editable do |idx, is_first, is_last, needs_signups, row_html| + %div{class: [group_class, (!is_last_group && is_last && 'end-group')]} + %div{class: needs_signups&&'open'}=row_html -if request.xhr? :javascript diff --git a/app/views/scheduler/calendar/show.html.haml b/app/views/scheduler/calendar/show.html.haml index 0abf2d28..ec943f57 100644 --- a/app/views/scheduler/calendar/show.html.haml +++ b/app/views/scheduler/calendar/show.html.haml @@ -1,7 +1,7 @@ = provide :header do %meta(name="pdfkit-orientation" content="Landscape") #calendar-title=render 'title', month: @month - + .calendar-config .row .col-sm-3 @@ -15,6 +15,7 @@ .btn-group-vertical.btn-block#highlighting-group %button.btn.btn-default.btn-block.active{:"data-style" => "highlight-my-shifts"} My Shifts %button.btn.btn-default.btn-block{:"data-style" => "highlight-open-shifts"} Open Shifts + %button.btn.btn-default.btn-block{:"data-style" => "highlight-recommended-shifts"} Recommended Shifts .col-sm-3 %h5 Show Shifts For .row diff --git a/spec/features/scheduler/view_recommended_shifts_spec.rb b/spec/features/scheduler/view_recommended_shifts_spec.rb new file mode 100644 index 00000000..21f9be86 --- /dev/null +++ b/spec/features/scheduler/view_recommended_shifts_spec.rb @@ -0,0 +1,9 @@ +require 'spec_helper' + +describe "View Recommended Shifts", type: :feature do + + it "should show a Recommended Shifts button" do + visit "/scheduler/calendar/2016/april" + expect(page).to have_selector(:link_or_button, 'Recommended Shifts') + end +end \ No newline at end of file diff --git a/spec/models/roster/person_spec.rb b/spec/models/roster/person_spec.rb index 56f35bb0..8e19e97c 100644 --- a/spec/models/roster/person_spec.rb +++ b/spec/models/roster/person_spec.rb @@ -48,9 +48,28 @@ membership.role_scopes.build scope: "424242" membership.save! - c = person.counties.create name: 'Test County', chapter: chapter + person.counties.create name: 'Test County', chapter: chapter expect(person.scope_for_role( grant_name)).to match_array(person.county_ids + [424242]) end end -end \ No newline at end of file + + describe "newness" do + let(:grant_name) { "test_grant" } + + it "returns 1.0 if less than 5 opportunites" do + rand(4).times {FactoryGirl.create(:responder_assignment, { person: person }) } + expect(person.newness_factor).to eq Roster::Person::NEWNESS_HIGH + end + + it "returns 0.7 if opportunites are less than or eq 5 and less than 20" do + rand(5...20).times { FactoryGirl.create :responder_assignment, { person: person } } + expect(person.newness_factor).to eq Roster::Person::NEWNESS_MED + end + + it "returns 0.5 if opportunites are greater or eq than 20" do + rand(20..25).times { FactoryGirl.create :responder_assignment, { person: person } } + expect(person.newness_factor).to eq Roster::Person::NEWNESS_LOW + end + end +end diff --git a/spec/models/scheduler/shift_recommendation_score_spec.rb b/spec/models/scheduler/shift_recommendation_score_spec.rb new file mode 100644 index 00000000..a89dea54 --- /dev/null +++ b/spec/models/scheduler/shift_recommendation_score_spec.rb @@ -0,0 +1,103 @@ +require 'spec_helper' + +describe Scheduler::ShiftRecommendationScore, :type => :model do + let(:incident) {FactoryGirl.create(:incident, chapter: shift.chapter)} + let(:chapter) {FactoryGirl.create :chapter} + let(:position) {FactoryGirl.create :position, chapter: chapter} + let(:county) {FactoryGirl.create :county, chapter: chapter} + let(:shift_group) {FactoryGirl.create :shift_group, chapter: chapter} + let(:shift) {FactoryGirl.create :shift, shift_groups: [shift_group], positions: [position], county: county} + let(:date) {shift.county.chapter.time_zone.today} + let(:person) { FactoryGirl.create :person, chapter: chapter, counties: [shift.county], positions: shift.positions} + + describe "get_score" do + it "returns a number" do + score = Scheduler::ShiftRecommendationScore.get_score(shift, shift_group, date) + expect(score).to be <= 5 + expect(score).to be >= 0 + end + end + + describe "shift_response_rate" do + it "returns rate of calls with positive responses" do + FactoryGirl.create(:responder_assignment, incident: incident) + FactoryGirl.create(:responder_assignment, incident: incident, role: Incidents::ResponderAssignment::RESPONSES.first) + + score = Scheduler::ShiftRecommendationScore.shift_response_rate(shift, shift_group, date) + expect(score).to eq 0.5 + end + + it "returns zero if no calls" do + score = Scheduler::ShiftRecommendationScore.shift_response_rate(shift, shift_group, date) + expect(score).to eq 0 + end + end + + describe "calls_with_positive_response" do + context "matches chapter" do + it "returns the number of calls including matching chapter" do + FactoryGirl.create(:responder_assignment, incident: incident) + score = Scheduler::ShiftRecommendationScore.calls_with_positive_response(shift, shift_group, date) + expect(score).to eq 1 + end + + it "does not calls that don't match chapter" do + incident_with_other_chapter = FactoryGirl.create(:incident, chapter: Roster::Chapter.new) + FactoryGirl.create(:responder_assignment, incident: incident_with_other_chapter) + score = Scheduler::ShiftRecommendationScore.calls_with_positive_response(shift, shift_group, date) + expect(score).to eq 0 + end + end + + context "positive response calls" do + it "returns number of calls including positive responses" do + FactoryGirl.create(:responder_assignment, incident: incident, role: Incidents::ResponderAssignment::ROLES.first) + score = Scheduler::ShiftRecommendationScore.calls_with_positive_response(shift, shift_group, date) + expect(score).to eq 1 + end + + it "doesn't include calls with negative responses" do + FactoryGirl.create(:responder_assignment, incident: incident, role: Incidents::ResponderAssignment::RESPONSES.first) + score = Scheduler::ShiftRecommendationScore.calls_with_positive_response(shift, shift_group, date) + expect(score).to eq 0 + end + end + + #waiting for nicolette's commit + xcontext "matches date" do + it "returns the number of calls including matching date and time of group" do + incident = FactoryGirl.create(:incident, chapter: shift.chapter) + FactoryGirl.create(:responder_assignment, incident: incident) + score = Scheduler::ShiftRecommendationScore.calls_with_positive_response(shift, shift_group, date) + expect(score).to eq 1 + end + + it "doesn't return calls that don't match date" do + incident = FactoryGirl.create(:incident, chapter: shift.chapter) + FactoryGirl.create(:responder_assignment, incident: incident) + score = Scheduler::ShiftRecommendationScore.calls_with_positive_response(shift, shift_group, 1.day.ago) + expect(score).to eq 0 + end + + it "doesn't return calls that don't match time offet of group" do + shift_group = FactoryGirl.create :shift_group, chapter: chapter, start_offset: 0, end_offset: 2.hours + incident = FactoryGirl.create(:incident, chapter: shift.chapter) + assignment = FactoryGirl.create(:responder_assignment, incident: incident) + assignment.update_attribute(:created_at, Time.current.beginning_of_day + 4.hours) + score = Scheduler::ShiftRecommendationScore.calls_with_positive_response(shift, shift_group, date) + expect(score).to eq 0 + end + end + end + + describe("unique_shift") do + it "to return a unqiue shift" do + shift_group = FactoryGirl.create :shift_group, chapter: chapter + day = "Monday" + + unique_shift = Scheduler::ShiftRecommendationScore.unique_shift(shift, shift_group, day ) + + expect(unique_shift[:day]).to eq day + end + end +end diff --git a/spec/models/scheduler/shift_spec.rb b/spec/models/scheduler/shift_spec.rb index b2c9be93..7a67c866 100644 --- a/spec/models/scheduler/shift_spec.rb +++ b/spec/models/scheduler/shift_spec.rb @@ -140,4 +140,5 @@ expect(shift.active_on_day?(date, shift_group)).to be_falsey end end + end