Skip to content

Commit

Permalink
Merge pull request #16017 from opf/feature/55997-allow-admins-to-choo…
Browse files Browse the repository at this point in the history
…se-between-display-in-hours-only-or-days-and-hours

[55997] Add duration_format setting with hours_only by default
  • Loading branch information
cbliard authored Jul 3, 2024
2 parents c77deb4 + 5ac1787 commit 2956352
Show file tree
Hide file tree
Showing 25 changed files with 250 additions and 114 deletions.
11 changes: 3 additions & 8 deletions app/services/duration_converter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,6 @@
# See COPYRIGHT and LICENSE files for more details.
# ++

# We use BigDecimal to handle floating point arithmetic and avoid
# weird floating point results on decimal operations when converting
# hours to seconds on duration outputting.
require "bigdecimal"

class DurationConverter
UNIT_ABBREVIATION_MAP = {
"seconds" => "seconds",
Expand Down Expand Up @@ -107,14 +102,14 @@ def output(duration_in_hours)

# :days_and_hours format return "0h" when parsing 0.
ChronicDuration.output(seconds,
format: :days_and_hours,
format:,
**duration_length_options)
end

private

def convert_duration_to_seconds(duration_in_hours)
(BigDecimal(duration_in_hours.to_s) * 3600).to_f
def format
Setting.duration_format == "days_and_hours" ? :days_and_hours : :hours_only
end

def duration_length_options
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ See COPYRIGHT and LICENSE files for more details.
<%= styled_form_tag(
admin_settings_working_days_and_hours_path,
method: :patch,
class: 'op-working-days-admin-settings'
class: "op-working-days-admin-settings"
) do %>

<section class="form--section">
Expand All @@ -43,12 +43,18 @@ See COPYRIGHT and LICENSE files for more details.
container_class: "-wide" %>
<div class="form--field-instructions"><%= t("setting_hours_per_day_explanation") %></div>
</div>
<div class="form--field">
<%= setting_select :duration_format,
Settings::Definition[:duration_format].allowed.collect { |f| [t("setting_duration_format_#{f}"), f] },
container_class: "-wide" %>
<div class="form--field-instructions"><%= t("setting_duration_format_instructions") %></div>
</div>
</fieldset>
</section>

<section class="form--section">
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= t('settings.working_days.section_work_week') %></legend>
<legend class="form--fieldset-legend"><%= t("settings.working_days.section_work_week") %></legend>
<div class="op-toast -warning -with-bottom-spacing">
<div class="op-toast--content">
<p>
Expand All @@ -62,26 +68,24 @@ See COPYRIGHT and LICENSE files for more details.

<div class="form--field op-working-days-admin-settings--day-selectors" id="setting_working_days">
<%= setting_multiselect :working_days,
I18n.t('date.day_names').rotate.zip(WeekDay::DAY_RANGE),
I18n.t("date.day_names").rotate.zip(WeekDay::DAY_RANGE),
direction: :horizontal %>
</div>
</fieldset>
</section>

<section class="form--section">
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= t('settings.working_days.section_holidays_and_closures') %></legend>
<legend class="form--fieldset-legend"><%= t("settings.working_days.section_holidays_and_closures") %></legend>

<p>
<%= t("working_days.instance_wide_info") %>
</p>
<%= angular_component_tag 'op-non-working-days-list',
<%= angular_component_tag "op-non-working-days-list",
data: { modified_non_working_days: @modified_non_working_days } %>

</fieldset>
</section>



<%= styled_button_tag t(:button_apply_changes), class: '-primary -with-icon icon-checkmark' %>
<%= styled_button_tag t(:button_apply_changes), class: "-primary -with-icon icon-checkmark" %>
<% end %>
5 changes: 5 additions & 0 deletions config/constants/settings/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,11 @@ class Definition
description: "Destroy all sessions for current_user on login",
default: false
},
duration_format: {
description: "Format for displaying durations",
default: "hours_only",
allowed: %w[days_and_hours hours_only]
},
edition: {
format: :string,
default: "standard",
Expand Down
10 changes: 7 additions & 3 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1474,7 +1474,7 @@ Project attributes and sections are defined in the <a href=%{admin_settings_url}
create_new_page: "Wiki page"

date:
abbr_day_names: [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ]
abbr_day_names: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
abbr_month_names:
[
~,
Expand Down Expand Up @@ -3131,6 +3131,10 @@ Project attributes and sections are defined in the <a href=%{admin_settings_url}
setting_default_projects_public: "New projects are public by default"
setting_diff_max_lines_displayed: "Max number of diff lines displayed"
setting_display_subprojects_work_packages: "Display subprojects work packages on main projects by default"
setting_duration_format: "Duration format"
setting_duration_format_hours_only: "Hours only"
setting_duration_format_days_and_hours: "Days and hours"
setting_duration_format_instructions: "This defines how Work, Remaining work, and Time spent durations are displayed."
setting_emails_footer: "Emails footer"
setting_emails_header: "Emails header"
setting_email_login: "Use email as login"
Expand All @@ -3143,8 +3147,8 @@ Project attributes and sections are defined in the <a href=%{admin_settings_url}
setting_host_name: "Host name"
setting_hours_per_day: "Hours per day"
setting_hours_per_day_explanation: >-
This will define what is considered a "day" when displaying duration in a more natural
way (for example, if a day is 8 hours, 32 hours would be 4 days).
This defines what is considered a "day" when displaying duration in days and hours
(for example, if a day is 8 hours, 32 hours would be 4 days).
setting_invitation_expiration_days: "Activation email expires after"
setting_work_package_done_ratio: "Progress calculation"
setting_work_package_done_ratio_field: "Work-based"
Expand Down
4 changes: 4 additions & 0 deletions docs/api/apiv3/components/schemas/configuration_model.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ properties:
description: Page size steps to be offered in paginated list UI
items:
type: integer
durationFormat:
type: string
description: The format used to display Work, Remaining Work, and Spent time durations
readOnly: true
activeFeatureFlags:
type: array
description: The list of all feature flags that are active
Expand Down
3 changes: 2 additions & 1 deletion docs/api/apiv3/paths/configuration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ get:
- 1
- 10
- 100
durationFormat: 'hours_only'
activeFeatureFlags:
- 'aFeatureFlag'
- 'anotherFeatureFlag'
Expand All @@ -28,7 +29,7 @@ get:
description: OK
headers: {}
tags:
- Configuration
- Configuration
description: ''
operationId: View_configuration
summary: View configuration
13 changes: 7 additions & 6 deletions docs/api/apiv3/tags/configuration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ description: |-
## Local Properties
| Property | Description | Type | Condition | Supported operations |
| :-----------------------: | -------------------------------------------------- | ---------- | ----------------- | -------------------- |
| maximumAttachmentFileSize | The maximum allowed size of an attachment in Bytes | Integer | | READ |
| perPageOptions | Page size steps to be offered in paginated list UI | Integer[] | | READ |
| hostName | The host name configured for the system | String | | READ |
| activeFeatureFlags | The list of all feature flags that are active | String[] | | READ |
| Property | Description | Type | Condition | Supported operations |
| :-----------------------: | -------------------------------------------------------------------------- | ---------- | ----------------- | -------------------- |
| maximumAttachmentFileSize | The maximum allowed size of an attachment in Bytes | Integer | | READ |
| perPageOptions | Page size steps to be offered in paginated list UI | Integer[] | | READ |
| hostName | The host name configured for the system | String | | READ |
| durationFormat | The format used to display Work, Remaining Work, and Spent time durations. | String | | READ |
| activeFeatureFlags | The list of all feature flags that are active | String[] | | READ |
name: Configuration
4 changes: 4 additions & 0 deletions frontend/src/app/core/config/configuration.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export class ConfigurationService {
return this.systemPreference('dateFormat');
}

public durationFormat():string {
return this.systemPreference('durationFormat');
}

public hoursPerDay():number {
return this.systemPreference('hoursPerDay');
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/core/datetime/timezone.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export class TimezoneService {
}

public formattedChronicDuration(durationString:string, opts = {
format: 'daysAndHours',
format: this.configurationService.durationFormat(),
hoursPerDay: this.configurationService.hoursPerDay(),
daysPerMonth: this.configurationService.daysPerMonth(),
}):string {
Expand Down
17 changes: 14 additions & 3 deletions frontend/src/app/shared/helpers/chronic_duration.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,10 @@ export function outputChronicDuration(seconds, opts = {}) {
const month = daysPerMonth * day;
const year = SECONDS_PER_YEAR;

if (units.seconds >= SECONDS_PER_YEAR && units.seconds % year < units.seconds % month) {
if (opts.format === 'hours_only') {
units.hours = seconds / 3600;
units.seconds = 0;
} else if (units.seconds >= SECONDS_PER_YEAR && units.seconds % year < units.seconds % month) {
units.years = Math.trunc(units.seconds / year);
units.months = Math.trunc((units.seconds % year) / month);
units.days = Math.trunc(((units.seconds % year) % month) / day);
Expand Down Expand Up @@ -330,9 +333,8 @@ export function outputChronicDuration(seconds, opts = {}) {
pluralize: true,
};
break;
case 'daysAndHours':
case 'days_and_hours':
dividers = {
// days: 'd',
hours: 'h',
keepZero: true,
};
Expand All @@ -350,6 +352,15 @@ export function outputChronicDuration(seconds, opts = {}) {
units.hours += (((units.minutes * 60) + units.seconds) / 3600.0)
units.hours = parseFloat(Math.round(units.hours * 100)) / 100;

break
case 'hours_only':
dividers = {
hours: 'h',
keepZero: true,
};

units.hours = parseFloat(Math.round(units.hours * 100)) / 100;

break
case 'chrono':
dividers = {
Expand Down
35 changes: 23 additions & 12 deletions frontend/src/app/shared/helpers/chronic_duration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,87 +160,98 @@ describe('outputChronicDuration', () => {
short: '1m 20s',
default: '1 min 20 secs',
long: '1 minute 20 seconds',
daysAndHours: '0.02h',
days_and_hours: '0.02h',
hours_only: '0.02h',
chrono: '1:20',
},
[60 + 20.51]: {
micro: '1m20.51s',
short: '1m 20.51s',
default: '1 min 20.51 secs',
long: '1 minute 20.51 seconds',
daysAndHours: '0.02h',
days_and_hours: '0.02h',
hours_only: '0.02h',
chrono: '1:20.51',
},
[60 + 20.51928]: {
micro: '1m20.51928s',
short: '1m 20.51928s',
default: '1 min 20.51928 secs',
long: '1 minute 20.51928 seconds',
daysAndHours: '0.02h',
days_and_hours: '0.02h',
hours_only: '0.02h',
chrono: '1:20.51928',
},
[4 * 3600 + 60 + 1]: {
micro: '4h1m1s',
short: '4h 1m 1s',
default: '4 hrs 1 min 1 sec',
long: '4 hours 1 minute 1 second',
daysAndHours: '4.02h',
days_and_hours: '4.02h',
hours_only: '4.02h',
chrono: '4:01:01',
},
[2 * 3600 + 20 * 60]: {
micro: '2h20m',
short: '2h 20m',
default: '2 hrs 20 mins',
long: '2 hours 20 minutes',
daysAndHours: '2.33h',
days_and_hours: '2.33h',
hours_only: '2.33h',
chrono: '2:20:00',
},
[8 * 24 * 3600 + 3 * 3600 + 30 * 60]: {
micro: '8d3h30m',
short: '8d 3h 30m',
default: '8 days 3 hrs 30 mins',
long: '8 days 3 hours 30 minutes',
daysAndHours: '8d 3.5h',
days_and_hours: '8d 3.5h',
hours_only: '195.5h',
chrono: '8:03:30:00'
},
[6 * 30 * 24 * 3600 + 24 * 3600]: {
micro: '6mo1d',
short: '6mo 1d',
default: '6 mos 1 day',
long: '6 months 1 day',
daysAndHours: '181d 0h',
days_and_hours: '181d 0h',
hours_only: '4344h',
chrono: '6:01:00:00:00', // Yuck. FIXME
},
[365.25 * 24 * 3600 + 24 * 3600]: {
micro: '1y1d',
short: '1y 1d',
default: '1 yr 1 day',
long: '1 year 1 day',
daysAndHours: '366d 0h',
days_and_hours: '366d 0h',
hours_only: '8790h',
chrono: '1:00:01:00:00:00',
},
[3 * 365.25 * 24 * 3600 + 24 * 3600]: {
micro: '3y1d',
short: '3y 1d',
default: '3 yrs 1 day',
long: '3 years 1 day',
daysAndHours: '1096d 0h',
days_and_hours: '1096d 0h',
hours_only: '26322h',
chrono: '3:00:01:00:00:00',
},
[6 * 365.25 * 24 * 3600 + 3 * 3600]: {
micro: '6y3h',
short: '6y 3h',
default: '6 yrs 3 hrs',
long: '6 years 3 hours',
daysAndHours: '2191d 3h',
days_and_hours: '2191d 3h',
hours_only: '52599h',
chrono: '6:00:00:03:00:00',
},
[3600 * 24 * 30 * 18]: {
micro: '18mo',
short: '18mo',
default: '18 mos',
long: '18 months',
daysAndHours: '540d 0h',
days_and_hours: '540d 0h',
hours_only: '12960h',
chrono: '18:00:00:00:00',
},
};
Expand Down Expand Up @@ -354,7 +365,7 @@ describe('outputChronicDuration', () => {
Object.entries(EXEMPLARS).forEach(([seconds, formatSpec]) => {
const secondsF = parseFloat(seconds);
Object.keys(formatSpec).forEach((format) => {
if (format === 'daysAndHours') return;
if (format === 'days_and_hours' || format === 'hours_only') return;

it(`outputs a duration for ${seconds} that parses back to the same thing when using the ${format} format`, () => {
expect(parseChronicDuration(outputChronicDuration(secondsF, { format }))).toBe(secondsF);
Expand Down
3 changes: 3 additions & 0 deletions lib/api/v3/configuration/configuration_representer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ class ConfigurationRepresenter < ::API::Decorators::Single
exec_context: :decorator,
render_nil: true

property :duration_format,
render_nil: true

property :time_format,
exec_context: :decorator,
render_nil: true
Expand Down
13 changes: 12 additions & 1 deletion lib/chronic_duration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ def output(seconds, opts = {})
month = days_per_month * day
year = SECONDS_PER_YEAR

if seconds >= SECONDS_PER_YEAR && seconds % year < seconds % month
if opts[:format] == :hours_only
hours = seconds / 3600.0
seconds = 0
elsif seconds >= SECONDS_PER_YEAR && seconds % year < seconds % month
years = seconds / year
months = seconds % year / month
days = seconds % year % month / day
Expand Down Expand Up @@ -173,6 +176,14 @@ def output(seconds, opts = {})
hours_int = hours.to_i
hours = hours_int if hours - hours_int == 0 # if hours end with .0
minutes = seconds = 0
when :hours_only
dividers = {
hours: "h", keep_zero: true
}

hours = hours.round(2)
hours_int = hours.to_i
hours = hours_int if hours - hours_int == 0 # if hours end with .0
when :chrono
dividers = {
years: ":", months: ":", weeks: ":", days: ":", hours: ":", minutes: ":", seconds: ":", keep_zero: true
Expand Down
Loading

0 comments on commit 2956352

Please sign in to comment.