-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Expand file tree
/
Copy pathuser_working_hours.rb
More file actions
166 lines (138 loc) · 5.54 KB
/
Copy pathuser_working_hours.rb
File metadata and controls
166 lines (138 loc) · 5.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
class UserWorkingHours < ApplicationRecord
DAYS = %i[monday tuesday wednesday thursday friday saturday sunday].freeze
# Maps each day symbol to the Rails I18n date.abbr_day_names index (Sunday = 0)
DAY_ABBR_INDEX = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 }.freeze
# Date#wday shares the Sunday = 0 numbering of DAY_ABBR_INDEX
WDAY_TO_DAY = DAY_ABBR_INDEX.invert.freeze
belongs_to :user, inverse_of: :working_hours
validates :valid_from, presence: true, uniqueness: { scope: :user_id }
validates :monday_hours, :tuesday_hours, :wednesday_hours, :thursday_hours, :friday_hours, :saturday_hours, :sunday_hours,
presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 24 }
validates :availability_factor,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
validate :at_least_one_working_day_selected
scope :for_user, ->(user) { where(user:) }
scope :past, ->(date = Date.current) { where(valid_from: ..date).order(valid_from: :desc) }
scope :upcoming, ->(date = Date.current) { where(valid_from: date..).order(valid_from: :asc) }
scope :history_for, ->(current_record) { where(valid_from: ..current_record.valid_from).order(valid_from: :desc) }
def self.valid_for_date(date)
where(valid_from: ..date).order(valid_from: :desc).first
end
def self.current
valid_for_date(Date.current)
end
scope :visible, ->(user = User.current) do
if user.allowed_globally?(:manage_working_times)
all
else
where(user:)
end
end
DAYS.each do |day|
define_method("#{day}_hours") do
minutes = public_send(day)
minutes.nil? ? nil : (minutes / 60.0).round(2)
end
define_method("#{day}_hours=") do |value|
hours = value.is_a?(String) ? (value.to_hours || value) : value
public_send("#{day}=", (hours.to_f * 60).round)
end
end
def minutes_on(date)
public_send(WDAY_TO_DAY.fetch(date.wday))
end
# The capacity for that day, i.e. the working minutes reduced by the availability factor.
def effective_minutes_on(date)
((minutes_on(date) * availability_factor) / 100.0).round
end
def weekly_working_hours
DAYS.sum { |day| public_send("#{day}_hours") }
end
def effective_weekly_working_hours
((weekly_working_hours * availability_factor) / 100.0).round(2)
end
# Returns the ranges of working days without hours, e.g. "Mon-Fri" or "Mon-Tue, Thu-Fri".
# Days are grouped by whether they are working days (minutes > 0), ignoring hour differences.
def working_day_ranges
DAYS
.map { |day| [day, public_send(day)] }
.chunk_while { |(_, m1), (_, m2)| m1.positive? == m2.positive? }
.select { |group| group.first.last.positive? }
.map { |group| format_day_range(group) }
.join(", ")
end
# Returns a human-readable summary of working days grouped by consecutive days
# with the same hours, e.g. "Mon-Thu 8h, Fri 6h" or "Mon-Tue 8h, Thu-Fri 8h".
def working_days_summary
DAYS
.map { |day| [day, public_send("#{day}_hours")] }
.chunk_while { |(_, h1), (_, h2)| h1 == h2 }
.reject { |group| group.first.last.zero? }
.map { |group| format_day_group(group) }
.join(", ")
end
private
def format_day_range(group)
first_day = group.first.first
last_day = group.last.first
if group.length == 1
full_day_name(first_day)
else
"#{full_day_name(first_day)}-#{full_day_name(last_day)}"
end
end
def format_day_group(group)
first_day = group.first.first
last_day = group.last.first
_, hours = group.first
range = group.length == 1 ? abbr_day_name(first_day) : "#{abbr_day_name(first_day)}-#{abbr_day_name(last_day)}"
"#{range} #{format_hours_str(hours)}"
end
def format_hours_str(hours)
rounded = hours.round(2)
return "#{rounded.to_i}h" if rounded == rounded.to_i
separator = I18n.t("number.format.separator")
"#{rounded.to_s.sub('.', separator)}h"
end
def full_day_name(day)
I18n.t("date.day_names")[DAY_ABBR_INDEX[day]]
end
def abbr_day_name(day)
I18n.t("date.abbr_day_names")[DAY_ABBR_INDEX[day]]
end
def at_least_one_working_day_selected
if DAYS.all? { |day| public_send(day).to_i.zero? }
errors.add(:days, :no_working_day)
end
end
end