Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3df2181
migrate to continuous assignments
benmelz Mar 13, 2026
1aebc37
barebones model revamp
benmelz Mar 19, 2026
394656c
get rosters index working in practice
benmelz Mar 19, 2026
bdb7d35
helpful scopes
benmelz Mar 30, 2026
5596c11
Merge branch 'main' into benmelz/assignments
benmelz Apr 2, 2026
997a182
Merge branch 'main' into benmelz/assignments
benmelz Apr 15, 2026
9aa572e
Fix rosters controller (#956)
zafnunhasnat-ops Apr 15, 2026
aa1279c
Merge branch 'main' into benmelz/assignments
benmelz Apr 21, 2026
0a6232f
Merge branch 'main' into benmelz/assignments
benmelz Apr 22, 2026
7329813
Fix assignment controller index.json (#960)
zafnunhasnat-ops Apr 22, 2026
8ca57ad
Merge branch 'main' into benmelz/assignments
benmelz Apr 30, 2026
3595979
Fix assignment forms (#988)
zafnunhasnat-ops Apr 30, 2026
413447d
Fix assignments csv (#994)
zafnunhasnat-ops Apr 30, 2026
e50d189
Merge branch 'main' into benmelz/assignments
benmelz Apr 30, 2026
e47e465
Merge branch 'main' into benmelz/assignments
benmelz May 4, 2026
76b20e9
Consider roster created_at values for assignment start datetimes (#1001)
benmelz May 4, 2026
ca0fa52
Fix versions and twilio spec (#1002)
zafnunhasnat-ops May 4, 2026
9abe1f1
Merge branch 'main' into benmelz/assignments
benmelz May 5, 2026
a96e03f
Merge branch 'main' into benmelz/assignments
benmelz May 6, 2026
6c9d0d0
Remove week and weekday assigners (#1010)
zafnunhasnat-ops May 6, 2026
ac20f0d
Merge branch 'main' into benmelz/assignments
benmelz May 12, 2026
3ed886d
Merge branch 'main' into benmelz/assignments
benmelz May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions app/controllers/assignments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,8 @@ class AssignmentsController < ApplicationController
def index
authorize!
respond_to do |format|
format.json do
@assignments = roster.assignments.between(Date.parse(params[:start_date]), Date.parse(params[:end_date]))
end
format.csv do
render csv: roster.assignment_csv, filename: roster.name
end
format.json { index_json }
format.csv { index_csv }
end
end

Expand Down Expand Up @@ -65,13 +61,19 @@ def find_assignment

def initialize_assignment
@assignment = roster.assignments.new
return if params[:date].blank?

@assignment.start_date = params.expect(:date).to_date
@assignment.end_date = @assignment.start_date + 6.days
end

def assignment_params
params.expect assignment: %i[start_date end_date user_id]
params.expect assignment: %i[end_datetime user_id]
end

def index_json
@assignments = roster.assignments.with_start_datetimes.preload(:user)
.where(start_datetime: nil..Date.parse(params[:end_date]).at_end_of_day,
end_datetime: Date.parse(params[:start_date]).at_beginning_of_day..nil)
end

def index_csv
render csv: roster.assignments.with_start_datetimes.order(end_datetime: :asc).to_csv, filename: roster.name
end
end
23 changes: 14 additions & 9 deletions app/controllers/rosters_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,13 @@ class RostersController < ApplicationController

def index
authorize!
@rosters = authorized_scope(Roster.all).page(params[:page])
@rosters = authorized_scope(Roster.includes(:fallback_user, current_assignment: :user)).page(params[:page])
end

def show
authorize! @roster, context: { api_key: }
respond_to do |format|
format.html do
@your_assignments = @roster.assignments.upcoming.joins(:user).where(user: Current.user).order(start_date: :asc)
end
format.json do
@upcoming = @roster.assignments.upcoming.order(:start_date)
end
format.html { show_html }
format.json { show_json }
end
end

Expand Down Expand Up @@ -75,5 +70,15 @@ def roster_params
params.expect roster: %i[name phone fallback_user_id switchover_time]
end

def api_key = request.headers['Authorization']&.split&.last
def show_html
authorize! @roster
@your_assignments = @roster.assignments.with_start_datetimes
.ending_after(Time.current).where(user: Current.user)
.order(end_datetime: :asc)
end

def show_json
authorize! @roster, context: { api_key: request.headers['Authorization']&.split&.last }
@upcoming = @roster.assignments.ending_after(Time.current).order(end_datetime: :asc)
end
end
37 changes: 0 additions & 37 deletions app/controllers/week_assigners_controller.rb

This file was deleted.

33 changes: 0 additions & 33 deletions app/controllers/weekday_assigners_controller.rb

This file was deleted.

12 changes: 1 addition & 11 deletions app/javascript/controllers/assignment_calendar_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import {Controller} from '@hotwired/stimulus';

export default class extends Controller {
static values = {
eventsUrl: String,
newAssignmentUrl: String,
};
static values = {eventsUrl: String};

connect() {
const calendar = new Calendar(this.element, {
Expand Down Expand Up @@ -51,12 +48,5 @@ export default class extends Controller {
});

calendar.render();

this.element.addEventListener('click', (e) => {
const dayElement = e.target.closest('td.day-empty');
if (dayElement) {
window.location = `${this.newAssignmentUrlValue}?date=` + dayElement.dataset.date;
}
});
}
}
165 changes: 42 additions & 123 deletions app/models/assignment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,142 +2,61 @@

class Assignment < ApplicationRecord
has_paper_trail
belongs_to :user
belongs_to :roster

validates :start_date, presence: true
validates :end_date, presence: true,
comparison: { greater_than_or_equal_to: :start_date,
if: -> { start_date.present? && end_date.present? },
message: :must_not_be_before_start }
validate :overlaps_none
validate :user_in_roster

after_commit :notify_user_of_assignment
after_commit :notify_user_of_change
after_commit :notify_user_of_removal

scope :future, -> { where 'start_date > ?', Time.zone.today }
scope :at, ->(time) { joins(:roster).where(start_time_node.lteq(time)).where(end_time_node.gt(time)) }

def effective_start_datetime
start_date + roster.switchover.minutes
end

# Assignments effectively end at the switchover hour on the following day.
def effective_end_datetime
end_date + 1.day + roster.switchover.minutes
end

class << self
def between(start_date, end_date)
where arel_table[:start_date].lteq(end_date).and(arel_table[:end_date].gteq(start_date))
end
attribute :start_datetime, :datetime

# The current assignment - this method accounts for the switchover hour.
# This should be called while scoped to a particular roster.
def current
joins(:roster).on(effective_date)
end
belongs_to :roster
belongs_to :user, optional: true

def effective_date
switchover = Roster.arel_table[:switchover]
yesterday = Arel::Nodes.build_quoted(Date.yesterday)
today = Arel::Nodes.build_quoted(Time.zone.today)
validates :end_datetime, comparison: { greater_than: ->(assignment) { assignment.roster.created_at },
if: ->(assignment) { assignment.roster.present? },
allow_nil: true },
presence: true,
uniqueness: { scope: :roster_id }

# If it's after the roster's switchover, use "Today", otherwise it's still "Yesterday".
# e.g. IF(1020 >= `roster`.`switchover`, '2023-09-01', '2023-08-31')
Arel::Nodes::NamedFunction.new('IF', [minutes_since_midnight.gteq(switchover), today, yesterday])
end
scope :ending_before, ->(time) { where(arel_table[:end_datetime].lt(time)) }
scope :ending_after, ->(time) { where(arel_table[:end_datetime].gt(time)) }

def start_time_node = time_node(:start_date)
def start_datetime = super.presence || previous&.end_datetime || roster.created_at

def end_time_node
Arel::Nodes::NamedFunction.new 'TIMESTAMPADD',
[Arel::Nodes::SqlLiteral.new('DAY'),
Arel::Nodes::SqlLiteral.new('1'),
time_node(:end_date)]
end
def previous = roster.assignments.ending_before(end_datetime).order(end_datetime: :desc).first

def in(roster)
where roster:
end

# returns the assignment which takes place on a particular date
def on(date)
between(date, date).first
end

def upcoming
joins(:roster).where arel_table[:start_date].gt(effective_date)
end
def next = roster.assignments.ending_after(end_datetime).order(end_datetime: :asc).first

def send_reminders!
where(start_date: Date.tomorrow).find_each do |assignment|
AssignmentsMailer.upcoming_reminder(assignment.roster, assignment.effective_start_datetime,
assignment.effective_end_datetime, assignment.user).deliver_now
class << self
def with_start_datetimes
from arel_table.join(Roster.arel_table).on(arel_table[:roster_id].eq(Roster.arel_table[:id]))
.project(arel_table[Arel.star], start_datetime_node)
.as(arel_table.name)
end

def to_csv # rubocop:disable Metrics
CSV.generate headers: %i[roster email first_name last_name start end created_at updated_at],
write_headers: true do |csv|
all.each do |assignment| # rubocop:disable Rails/FindEach
csv << {
roster: assignment.roster.name,
email: assignment.user.email,
first_name: assignment.user.first_name,
last_name: assignment.user.last_name,
start: assignment.start_datetime.iso8601,
end: assignment.end_datetime.iso8601,
created_at: assignment.created_at.iso8601,
updated_at: assignment.updated_at.iso8601
}
end
end
end

private

def time_node(column)
Arel::Nodes::NamedFunction.new 'TIMESTAMPADD',
[Arel::Nodes::SqlLiteral.new('MINUTE'),
Roster.arel_table[:switchover],
arel_table[column]]
def start_datetime_node
Arel::Nodes::Over.new(
Arel::Nodes::NamedFunction.new('LAG', [arel_table[:end_datetime],
Arel::Nodes::SqlLiteral.new('1'),
Roster.arel_table[:created_at]]),
Arel::Nodes::Window.new.partition(arel_table[:roster_id]).order(arel_table[:end_datetime])
).as(arel_table[:start_datetime].name)
end
end

private

def overlaps_none
# A non-new record always overlaps itself, so we exclude it from our query.
return if roster.assignments
.where('`start_date` <= ? AND `end_date` >= ?', end_date, start_date)
.excluding(self)
.none?

errors.add :base, 'Overlaps with another assignment'
end

def notify_user_of_assignment
return unless user_id_previously_changed?
return if user == Current.user
return unless user.change_notifications_enabled?

AssignmentsMailer.new_assignment(roster, effective_start_datetime, effective_end_datetime, user, Current.user)
.deliver_later
end

def notify_user_of_change
return if previously_new_record?
return if user_id_previously_changed?
return unless start_date_previously_changed? || end_date_previously_changed?
return if user == Current.user
return unless user.change_notifications_enabled?

AssignmentsMailer.changed_assignment(roster, effective_start_datetime, effective_end_datetime, user, Current.user)
.deliver_later
end

def notify_user_of_removal
return unless previously_persisted? || user_id_previously_changed?

previous_user = previously_persisted? ? user : User.find_by(id: user_id_previously_was)
return if previous_user.blank?
return if previous_user == Current.user
return unless previous_user.change_notifications_enabled?

AssignmentsMailer.deleted_assignment(roster,
effective_start_datetime, effective_end_datetime,
previous_user, Current.user)
.deliver_later
end

def user_in_roster
return if roster.users.include? user

errors.add :base, 'User is not in this roster'
end
end
2 changes: 1 addition & 1 deletion app/models/membership.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ class Membership < ApplicationRecord
private

def delete_future_assignments
user.assignments.where(roster:).future.each(&:destroy!)
Assignment.where(roster:, user:).ending_after(Time.current).find_each { |assignment| assignment.update!(user: nil) }
end
end
Loading
Loading