Commit b36db157 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Limit time tracking values to hours

Adds an instance setting to limit display of time tracking
values to hours only
parent 9944ee53
......@@ -38,6 +38,7 @@ export default Vue.extend({
issue: {},
list: {},
loadingAssignees: false,
timeTrackingLimitToHours: boardsStore.timeTracking.limitToHours,
};
},
computed: {
......
......@@ -2,6 +2,7 @@
import { GlTooltip } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
import boardsStore from '../stores/boards_store';
export default {
components: {
......@@ -14,12 +15,17 @@ export default {
required: true,
},
},
data() {
return {
limitToHours: boardsStore.timeTracking.limitToHours,
};
},
computed: {
title() {
return stringifyTime(parseSeconds(this.estimate), true);
return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }), true);
},
timeEstimate() {
return stringifyTime(parseSeconds(this.estimate));
return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }));
},
},
};
......
......@@ -56,6 +56,7 @@ export default () => {
}
boardsStore.create();
boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
issueBoardsApp = new Vue({
el: $boardApp,
......
......@@ -12,6 +12,9 @@ import eventHub from '../eventhub';
const boardsStore = {
disabled: false,
timeTracking: {
limitToHours: false,
},
scopedLabels: {
helpLink: '',
enabled: false,
......@@ -222,6 +225,10 @@ const boardsStore = {
setIssueDetail(issueDetail) {
this.detail.issue = issueDetail;
},
setTimeTrackingLimitToHours(limitToHours) {
this.timeTracking.limitToHours = parseBoolean(limitToHours);
},
};
BoardsStoreEE.initEESpecific(boardsStore);
......
......@@ -479,9 +479,13 @@ export const pikadayToString = date => {
* Seconds can be negative or positive, zero or non-zero. Can be configured for any day
* or week length.
*/
export const parseSeconds = (seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) => {
export const parseSeconds = (
seconds,
{ daysPerWeek = 5, hoursPerDay = 8, limitToHours = false } = {},
) => {
const DAYS_PER_WEEK = daysPerWeek;
const HOURS_PER_DAY = hoursPerDay;
const SECONDS_PER_MINUTE = 60;
const MINUTES_PER_HOUR = 60;
const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR;
const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
......@@ -493,9 +497,18 @@ export const parseSeconds = (seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {})
minutes: 1,
};
let unorderedMinutes = Math.abs(seconds / MINUTES_PER_HOUR);
if (limitToHours) {
timePeriodConstraints.weeks = 0;
timePeriodConstraints.days = 0;
}
let unorderedMinutes = Math.abs(seconds / SECONDS_PER_MINUTE);
return _.mapObject(timePeriodConstraints, minutesPerPeriod => {
if (minutesPerPeriod === 0) {
return 0;
}
const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
unorderedMinutes -= periodCount * minutesPerPeriod;
......
......@@ -28,11 +28,16 @@ export default {
type: String,
required: true,
},
limitToHours: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
parsedTimeRemaining() {
const diffSeconds = this.timeEstimate - this.timeSpent;
return parseSeconds(diffSeconds);
return parseSeconds(diffSeconds, { limitToHours: this.limitToHours });
},
timeRemainingHumanReadable() {
return stringifyTime(this.parsedTimeRemaining);
......
......@@ -53,6 +53,7 @@ export default {
:time-spent="store.totalTimeSpent"
:human-time-estimate="store.humanTimeEstimate"
:human-time-spent="store.humanTotalTimeSpent"
:limit-to-hours="store.timeTrackingLimitToHours"
:root-path="store.rootPath"
/>
</div>
......
......@@ -37,6 +37,10 @@ export default {
required: false,
default: '',
},
limitToHours: {
type: Boolean,
default: false,
},
rootPath: {
type: String,
required: true,
......@@ -129,6 +133,7 @@ export default {
:time-spent="timeSpent"
:time-spent-human-readable="humanTimeSpent"
:time-estimate-human-readable="humanTimeEstimate"
:limit-to-hours="limitToHours"
/>
<transition name="help-state-toggle">
<time-tracking-help-state v-if="showHelpState" :root-path="rootPath" />
......
import Vue from 'vue';
import timeTracker from './components/time_tracking/time_tracker.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
export default class SidebarMilestone {
constructor() {
......@@ -7,7 +8,7 @@ export default class SidebarMilestone {
if (!el) return;
const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = el.dataset;
const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent, limitToHours } = el.dataset;
// eslint-disable-next-line no-new
new Vue({
......@@ -22,6 +23,7 @@ export default class SidebarMilestone {
timeSpent: parseInt(timeSpent, 10),
humanTimeEstimate,
humanTimeSpent,
limitToHours: parseBoolean(limitToHours),
rootPath: '/',
},
}),
......
......@@ -8,7 +8,7 @@ export default class SidebarStore {
}
initSingleton(options) {
const { currentUser, rootPath, editable } = options;
const { currentUser, rootPath, editable, timeTrackingLimitToHours } = options;
this.currentUser = currentUser;
this.rootPath = rootPath;
this.editable = editable;
......@@ -16,6 +16,7 @@ export default class SidebarStore {
this.totalTimeSpent = 0;
this.humanTimeEstimate = '';
this.humanTimeSpent = '';
this.timeTrackingLimitToHours = timeTrackingLimitToHours;
this.assignees = [];
this.isFetching = {
assignees: true,
......
......@@ -253,6 +253,7 @@ module ApplicationSettingsHelper
:throttle_unauthenticated_enabled,
:throttle_unauthenticated_period_in_seconds,
:throttle_unauthenticated_requests_per_period,
:time_tracking_limit_to_hours,
:two_factor_grace_period,
:unique_ips_limit_enabled,
:unique_ips_limit_per_user,
......
......@@ -14,7 +14,8 @@ module BoardsHelper
issue_link_base: build_issue_link_base,
root_path: root_path,
bulk_update_path: @bulk_issues_path,
default_avatar: image_path(default_avatar)
default_avatar: image_path(default_avatar),
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s
}
end
......
......@@ -430,7 +430,8 @@ module IssuablesHelper
editable: issuable.dig(:current_user, :can_edit),
currentUser: issuable[:current_user],
rootPath: root_path,
fullPath: issuable[:project_full_path]
fullPath: issuable[:project_full_path],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours
}
end
......
......@@ -82,6 +82,7 @@ module ApplicationSettingImplementation
throttle_unauthenticated_enabled: false,
throttle_unauthenticated_period_in_seconds: 3600,
throttle_unauthenticated_requests_per_period: 3600,
time_tracking_limit_to_hours: false,
two_factor_grace_period: 48,
unique_ips_limit_enabled: false,
unique_ips_limit_per_user: 10,
......
......@@ -8,4 +8,11 @@
.form-text.text-muted
= _('Default first day of the week in calendars and date pickers.')
.form-group
= f.label :time_tracking, _('Time tracking'), class: 'label-bold'
.form-check
= f.check_box :time_tracking_limit_to_hours, class: 'form-check-input'
= f.label :time_tracking_limit_to_hours, class: 'form-check-label' do
= _('Limit display of time tracking units to hours.')
= f.submit _('Save changes'), class: "btn btn-success"
......@@ -3,4 +3,5 @@
":time-spent" => "issue.timeSpent || 0",
":human-time-estimate" => "issue.humanTimeEstimate",
":human-time-spent" => "issue.humanTimeSpent",
":limit-to-hours" => "timeTrackingLimitToHours",
"root-path" => "#{root_url}" }
......@@ -93,7 +93,11 @@
= milestone.issues_visible_to_user(current_user).closed.count
.block
#issuable-time-tracker{ data: { time_estimate: @milestone.total_issue_time_estimate, time_spent: @milestone.total_issue_time_spent, human_time_estimate: @milestone.human_total_issue_time_estimate, human_time_spent: @milestone.human_total_issue_time_spent } }
#issuable-time-tracker{ data: { time_estimate: @milestone.total_issue_time_estimate,
time_spent: @milestone.total_issue_time_spent,
human_time_estimate: @milestone.human_total_issue_time_estimate,
human_time_spent: @milestone.human_total_issue_time_spent,
limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s } }
// Fallback while content is loading
.title.hide-collapsed
= _('Time tracking')
......
---
title: Add option to limit time tracking units to hours
merge_request: 29469
author: Jon Kolb
type: added
# frozen_string_literal: true
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddTimeTrackingLimitToHoursToApplicationSettings < ActiveRecord::Migration[5.1]
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
disable_ddl_transaction!
def up
add_column_with_default :application_settings, :time_tracking_limit_to_hours, :boolean, default: false, allow_null: false
end
def down
remove_column :application_settings, :time_tracking_limit_to_hours
end
end
......@@ -229,6 +229,7 @@ ActiveRecord::Schema.define(version: 20190620112608) do
t.boolean "dns_rebinding_protection_enabled", default: true, null: false
t.boolean "default_project_deletion_protection", default: false, null: false
t.boolean "lock_memberships_to_ldap", default: false, null: false
t.boolean "time_tracking_limit_to_hours", default: false, null: false
t.index ["custom_project_templates_group_id"], name: "index_application_settings_on_custom_project_templates_group_id", using: :btree
t.index ["file_template_project_id"], name: "index_application_settings_on_file_template_project_id", using: :btree
t.index ["usage_stats_set_by_user_id"], name: "index_application_settings_on_usage_stats_set_by_user_id", using: :btree
......
......@@ -273,6 +273,7 @@ are listed in the descriptions of the relevant settings.
| `throttle_unauthenticated_enabled` | boolean | no | (**If enabled, requires:** `throttle_unauthenticated_period_in_seconds` and `throttle_unauthenticated_requests_per_period`) Enable unauthenticated request rate limit. Helps reduce request volume (e.g. from crawlers or abusive bots). |
| `throttle_unauthenticated_period_in_seconds` | integer | required by: `throttle_unauthenticated_enabled` | Rate limit period in seconds. |
| `throttle_unauthenticated_requests_per_period` | integer | required by: `throttle_unauthenticated_enabled` | Max requests per period per IP. |
| `time_tracking_limit_to_hours` | boolean | no | Limit display of time tracking units to hours. Default is `false`. |
| `two_factor_grace_period` | integer | required by: `require_two_factor_authentication` | Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication. |
| `unique_ips_limit_enabled` | boolean | no | (**If enabled, requires:** `unique_ips_limit_per_user` and `unique_ips_limit_time_window`) Limit sign in from multiple ips. |
| `unique_ips_limit_per_user` | integer | required by: `unique_ips_limit_enabled` | Maximum number of ips per user. |
......
......@@ -73,7 +73,15 @@ The following time units are available:
Default conversion rates are 1mo = 4w, 1w = 5d and 1d = 8h.
Other interesting links:
### Limit displayed units to hours
> Introduced in GitLab 12.0.
The display of time units can be limited to hours through the option in **Admin Area > Settings > Preferences** under 'Localization'.
With this option enabled, `75h` is displayed instead of `1w 4d 3h`.
## Other interesting links
- [Time Tracking landing page on about.gitlab.com](https://about.gitlab.com/solutions/time-tracking/)
......
......@@ -6,7 +6,7 @@ module Gitlab
def parse(string)
with_custom_config do
string.sub!(/\A-/, '')
string = string.sub(/\A-/, '')
seconds = ChronicDuration.parse(string, default_unit: 'hours') rescue nil
seconds *= -1 if seconds && Regexp.last_match
......@@ -16,10 +16,12 @@ module Gitlab
def output(seconds)
with_custom_config do
ChronicDuration.output(seconds, format: :short, limit_to_hours: false, weeks: true) rescue nil
ChronicDuration.output(seconds, format: :short, limit_to_hours: limit_to_hours_setting, weeks: true) rescue nil
end
end
private
def with_custom_config
# We may want to configure it through project settings in a future version.
ChronicDuration.hours_per_day = 8
......@@ -32,5 +34,9 @@ module Gitlab
result
end
def limit_to_hours_setting
Gitlab::CurrentSettings.time_tracking_limit_to_hours
end
end
end
......@@ -7874,6 +7874,9 @@ msgstr ""
msgid "Licenses"
msgstr ""
msgid "Limit display of time tracking units to hours."
msgstr ""
msgid "Limit namespaces and projects that can be indexed"
msgstr ""
......
......@@ -16,7 +16,9 @@ describe 'Issue Boards', :js do
let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch], relative_position: 1) }
let(:board) { create(:board, project: project) }
let!(:list) { create(:list, board: board, label: development, position: 0) }
let(:card) { find('.board:nth-child(2)').first('.board-card') }
let(:card) { find('.board:nth-child(2)').first('.board-card') }
let(:application_settings) { {} }
around do |example|
Timecop.freeze { example.run }
......@@ -27,6 +29,8 @@ describe 'Issue Boards', :js do
sign_in(user)
stub_application_setting(application_settings)
visit project_board_path(project, board)
wait_for_requests
end
......@@ -223,16 +227,24 @@ describe 'Issue Boards', :js do
end
context 'time tracking' do
let(:compare_meter_tooltip) { find('.time-tracking .time-tracking-content .compare-meter')['data-original-title'] }
before do
issue2.timelogs.create(time_spent: 14400, user: user)
issue2.update!(time_estimate: 28800)
issue2.update!(time_estimate: 128800)
click_card(card)
end
it 'shows time tracking progress bar' do
click_card(card)
expect(compare_meter_tooltip).to eq('Time remaining: 3d 7h 46m')
end
context 'when time_tracking_limit_to_hours is true' do
let(:application_settings) { { time_tracking_limit_to_hours: true } }
page.within('.time-tracking') do
expect(find('.time-tracking-content .compare-meter')['data-original-title']).to eq('Time remaining: 4h')
it 'shows time tracking progress bar' do
expect(compare_meter_tooltip).to eq('Time remaining: 31h 46m')
end
end
end
......
......@@ -334,6 +334,12 @@ describe('prettyTime methods', () => {
assertTimeUnits(aboveOneDay, 33, 2, 2, 0);
assertTimeUnits(aboveOneWeek, 26, 0, 1, 9);
});
it('should correctly parse values when limitedToHours is true', () => {
const twoDays = datetimeUtility.parseSeconds(173000, { limitToHours: true });
assertTimeUnits(twoDays, 3, 48, 0, 0);
});
});
describe('stringifyTime', () => {
......
......@@ -355,4 +355,14 @@ describe('Store', () => {
expect(boardsStore.moving.list).toEqual(dummyList);
});
});
describe('setTimeTrackingLimitToHours', () => {
it('sets the timeTracking.LimitToHours option', () => {
boardsStore.timeTracking.limitToHours = false;
boardsStore.setTimeTrackingLimitToHours('true');
expect(boardsStore.timeTracking.limitToHours).toEqual(true);
});
});
});
import Vue from 'vue';
import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue';
import boardsStore from '~/boards/stores/boards_store';
import mountComponent from '../../helpers/vue_mount_component_helper';
describe('Issue Tine Estimate component', () => {
describe('Issue Time Estimate component', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(IssueTimeEstimate);
vm = mountComponent(Component, {
estimate: 374460,
});
boardsStore.create();
});
afterEach(() => {
vm.$destroy();
});
it('renders the correct time estimate', () => {
expect(vm.$el.querySelector('time').textContent.trim()).toEqual('2w 3d 1m');
});
describe('when limitToHours is false', () => {
beforeEach(() => {
boardsStore.timeTracking.limitToHours = false;
const Component = Vue.extend(IssueTimeEstimate);
vm = mountComponent(Component, {
estimate: 374460,
});
});
it('renders the correct time estimate', () => {
expect(vm.$el.querySelector('time').textContent.trim()).toEqual('2w 3d 1m');
});
it('renders expanded time estimate in tooltip', () => {
expect(vm.$el.querySelector('.js-issue-time-estimate').textContent).toContain(
'2 weeks 3 days 1 minute',
);
});
it('prevents tooltip xss', done => {
const alertSpy = spyOn(window, 'alert');
vm.estimate = 'Foo <script>alert("XSS")</script>';
it('renders expanded time estimate in tooltip', () => {
expect(vm.$el.querySelector('.js-issue-time-estimate').textContent).toContain(
'2 weeks 3 days 1 minute',
);
vm.$nextTick(() => {
expect(alertSpy).not.toHaveBeenCalled();
expect(vm.$el.querySelector('time').textContent.trim()).toEqual('0m');
expect(vm.$el.querySelector('.js-issue-time-estimate').textContent).toContain('0m');
done();
});
});
});
it('prevents tooltip xss', done => {
const alertSpy = spyOn(window, 'alert');
vm.estimate = 'Foo <script>alert("XSS")</script>';
describe('when limitToHours is true', () => {
beforeEach(() => {
boardsStore.timeTracking.limitToHours = true;
const Component = Vue.extend(IssueTimeEstimate);
vm = mountComponent(Component, {
estimate: 374460,
});
});
it('renders the correct time estimate', () => {
expect(vm.$el.querySelector('time').textContent.trim()).toEqual('104h 1m');
});
vm.$nextTick(() => {
expect(alertSpy).not.toHaveBeenCalled();
expect(vm.$el.querySelector('time').textContent.trim()).toEqual('0m');
expect(vm.$el.querySelector('.js-issue-time-estimate').textContent).toContain('0m');
done();
it('renders expanded time estimate in tooltip', () => {
expect(vm.$el.querySelector('.js-issue-time-estimate').textContent).toContain(
'104 hours 1 minute',
);
});
});
});
......@@ -13,6 +13,7 @@ describe('Issuable Time Tracker', () => {
timeSpent,
timeEstimateHumanReadable,
timeSpentHumanReadable,
limitToHours,
}) => {
setFixtures(`
<div>
......@@ -25,6 +26,7 @@ describe('Issuable Time Tracker', () => {
timeSpent,
humanTimeEstimate: timeEstimateHumanReadable,
humanTimeSpent: timeSpentHumanReadable,
limitToHours: Boolean(limitToHours),
rootPath: '/',
};
......@@ -128,6 +130,29 @@ describe('Issuable Time Tracker', () => {
});
});
describe('Comparison pane when limitToHours is true', () => {
beforeEach(() => {
initTimeTrackingComponent({
timeEstimate: 100000, // 1d 3h
timeSpent: 5000, // 1h 23m
timeEstimateHumanReadable: '',
timeSpentHumanReadable: '',
limitToHours: true,
});
});
it('should show the correct tooltip text', done => {
Vue.nextTick(() => {
expect(vm.showComparisonState).toBe(true);
const $title = vm.$el.querySelector('.time-tracking-content .compare-meter').dataset
.originalTitle;
expect($title).toBe('Time remaining: 26h 23m');
done();
});
});
});
describe('Estimate only pane', () => {
beforeEach(() => {
initTimeTrackingComponent({
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::TimeTrackingFormatter do
describe '#parse' do
subject { described_class.parse(duration_string) }
context 'positive durations' do
let(:duration_string) { '3h 20m' }
it { expect(subject).to eq(12_000) }
end
context 'negative durations' do
let(:duration_string) { '-3h 20m' }
it { expect(subject).to eq(-12_000) }
end
end
describe '#output' do
let(:num_seconds) { 178_800 }
subject { described_class.output(num_seconds) }
context 'time_tracking_limit_to_hours setting is true' do
before do
stub_application_setting(time_tracking_limit_to_hours: true)
end
it { expect(subject).to eq('49h 40m') }
end
context 'time_tracking_limit_to_hours setting is false' do
before do
stub_application_setting(time_tracking_limit_to_hours: false)
end
it { expect(subject).to eq('1w 1d 1h 40m') }
end
end
end
......@@ -946,6 +946,18 @@ describe SystemNoteService do
expect(subject.note).to eq "changed time estimate to 1w 4d 5h"
end
context 'when time_tracking_limit_to_hours setting is true' do
before do
stub_application_setting(time_tracking_limit_to_hours: true)
end
it 'sets the note text' do
noteable.update_attribute(:time_estimate, 277200)
expect(subject.note).to eq "changed time estimate to 77h"
end
end
end
context 'without a time estimate' do
......@@ -1022,6 +1034,18 @@ describe SystemNoteService do
end
end
context 'when time_tracking_limit_to_hours setting is true' do
before do
stub_application_setting(time_tracking_limit_to_hours: true)
end
it 'sets the note text' do
spend_time!(277200)
expect(subject.note).to eq "added 77h of time spent"
end
end
def spend_time!(seconds)
noteable.spend_time(duration: seconds, user_id: author.id)
noteable.save!
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment