31 Commits

Author SHA1 Message Date
Jens Kraemer
8e34eb627c fixes various UI issues 2025-06-24 15:48:03 +08:00
Jens Kraemer
3c709b1329 v1.1 for Redmine 6 2025-06-24 15:26:21 +08:00
Jens Kraemer
776c0cd823 switch to svg icons 2025-06-07 11:41:49 +08:00
Jens Krämer
e40840b974 Update README.md 2023-11-03 15:19:40 +08:00
Jens Kraemer
efc8c6c620 require_relative confuses the CI action 2023-11-03 15:17:22 +08:00
Jens Kraemer
33e8d85f8f remove travis leftovers 2023-11-03 15:04:49 +08:00
Jens Kraemer
7f0581aff2 travis -> GH actions 2023-11-03 14:41:58 +08:00
Jens Kraemer
c496fac5e2 Redmine 5 2023-11-03 14:41:58 +08:00
Jens Kraemer
45d15917b3 update travis badge and link 2021-10-06 13:11:19 +08:00
Jens Kraemer
fd7a5375e2 travis: do not attempt to run 4.1 with ruby 2.7 2021-10-06 13:02:42 +08:00
Jens Kraemer
8db0686e3a travis 2021-10-06 12:24:39 +08:00
Jens Kraemer
e435d5f350 travis 2021-10-06 12:11:06 +08:00
Jens Kraemer
8e572e79b5 Merge branch 'feature/start-stop-ticket' 2021-10-06 10:58:37 +08:00
Jens Kraemer
88c1748703 travis 2021-10-05 12:10:13 +08:00
Jens Kraemer
84a49f1d1c test feature branch, update ruby versions 2021-10-05 11:40:20 +08:00
Jens Kraemer
78e47f5b75 start/stop tracking: issue context menu entry 2021-10-05 11:26:51 +08:00
Jens Kraemer
033e1739d2 'Start/Stop tracking' on issues#show 2021-10-05 11:26:51 +08:00
Jens Kraemer
bd6711a59f plugin config - default activity setting
- this can be set to make the new 'Start tracking for issue' function a
  one click operation
2021-10-05 11:26:51 +08:00
Jens Kraemer
d32845cc6e extract controller base class 2021-10-05 11:26:51 +08:00
Jens Kraemer
317aa08365 time entry form fixes
- issue selection selects the project accordingly
- issue / project selection updates available activities
- move project / issue selector to top. more logical this way, since
making a selection there may change the available activities
2021-10-05 11:26:51 +08:00
Jens Kraemer
b4af7e0eb3 adds project_id arg to 'log time' link to preselect the current project, if any. 2021-10-05 11:26:34 +08:00
Jens Kraemer
a2096e3208 remove running timer info when the time entry is removed 2021-10-05 10:44:37 +08:00
Jens Kraemer
53d5fbafd2 cleanup test helper 2021-08-31 14:27:50 +08:00
Jens Kraemer
6c51ef795a always show the running timer in the timers list #5 2021-08-31 14:24:24 +08:00
jan-imrich
9802307e5e fix rounding problem
Fixes the rounding problem. Since entry.hours is an getter with integrated round(2), its result should not be used for spent time calculation. Given the update function is called quite often (every 1-2 minutes), especially when the redmine issue is open in browser, it can disrupt the resulting overall logged time by up to 50%.
2020-12-19 08:44:15 +08:00
Jens Krämer
742157a1f1 Merge pull request #2 from vividtone/custom_fields_helper-is-missing
thanks!
2020-08-19 07:45:26 +08:00
MAEDA Go
4c9539c59a Clicking "Log time" causes NoMethodError 2020-08-04 23:14:37 +09:00
Jens Kraemer
c53f1d006c travis 2020-07-24 11:32:42 +08:00
Jens Kraemer
37d47f58cf fix travis(?) 2020-07-24 08:16:13 +08:00
Jens Kraemer
049cbd6c8f travis 2020-07-24 07:59:14 +08:00
Jens Kraemer
1ed6942f0c fix test 2020-07-24 07:59:12 +08:00
38 changed files with 666 additions and 66 deletions

32
.github/workflows/redmine.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Test with Redmine
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
redmine:
- '5.0'
- '5.1'
ruby:
- '3.0'
- '3.1'
database:
- postgresql
- mysql
steps:
- uses: eXolnet/action-redmine-plugin@v1
with:
plugin_name: stopwatch
redmine_version: ${{ matrix.redmine }}
ruby_version: ${{ matrix.ruby }}
database: ${{ matrix.database }}

View File

@@ -1,4 +1,4 @@
# Stopwatch Plugin for Redmine
# Stopwatch Plugin for Redmine [![Test with Redmine](https://github.com/jkraemer/stopwatch/actions/workflows/redmine.yml/badge.svg)](https://github.com/jkraemer/stopwatch/actions/workflows/redmine.yml)
Minimal plugin that aims to make tracking your time with Redmine much easier.

View File

@@ -0,0 +1,12 @@
# base class for stopwatch controllers
class StopwatchController < ApplicationController
helper :timelog, :custom_fields
before_action :require_login
private
def authorize_edit_time
@time_entry.editable_by?(User.current) or deny_access
end
end

View File

@@ -0,0 +1,48 @@
class StopwatchIssueTimersController < StopwatchController
before_action :find_issue
before_action :authorize_log_time
def start
t = Stopwatch::IssueTimer.new(issue: @issue)
if t.running?
head 422; return
end
time_entry = User.current.todays_time_entry_for(@issue)
if time_entry.new_record?
sys_default_activity = time_entry.activity
time_entry.activity = Stopwatch.default_activity_for time_entry
end
r = Stopwatch::StartTimer.new(time_entry).call
if r.success?
@started_time_entry = time_entry
render status: :created
else
@time_entry = time_entry
# in case the setting is 'always ask', still preset the form to the global default
@time_entry.activity ||= sys_default_activity
@time_entry.errors.clear
render status: :ok
end
end
def stop
r = Stopwatch::StopTimer.new.call
unless r.success?
logger.error "unable to stop timer"
head 422; return
end
end
private
def authorize_log_time
User.current.allowed_to?(:log_time, @project) or deny_access
end
def find_issue
@issue = Issue.find params[:issue_id]
@project = @issue.project
end
end

View File

@@ -8,10 +8,8 @@
# - same for project, unless we are in a project context
# - focus first field that needs an action, depending on above
#
class StopwatchTimersController < ApplicationController
helper :timelog
class StopwatchTimersController < StopwatchController
before_action :require_login
before_action :find_optional_data, only: %i(new create)
before_action :authorize_log_time, only: %i(new create start stop current)
before_action :find_time_entry, only: %i(edit update start stop)
@@ -62,7 +60,9 @@ class StopwatchTimersController < ApplicationController
def stop
r = Stopwatch::StopTimer.new.call
unless r.success?
if r.success?
@stopped_time_entry = @time_entry
else
logger.error "unable to stop timer"
end
new unless params[:context]
@@ -73,6 +73,17 @@ class StopwatchTimersController < ApplicationController
render json: @timer.to_json
end
def update_form
if id = params[:time_entry_id].presence
@time_entry = TimeEntry.visible.find id
else
@time_entry = TimeEntry.new
end
@time_entry.safe_attributes = params[:time_entry]
rescue ActiveRecord::RecordNotFound
head 404
end
private
def find_timer
@@ -82,11 +93,12 @@ class StopwatchTimersController < ApplicationController
def find_time_entry
@time_entry = time_entries.find params[:id]
end
def load_todays_entries
@entries = time_entries.where(spent_on: User.current.today).order(created_on: :asc)
@entries = time_entries.where(spent_on: User.current.today).or(
time_entries.where(id: User.current.running_time_entry_id)
).order(created_on: :asc)
end
def time_entries

View File

@@ -0,0 +1,4 @@
<p>
<label for="settings_default_activity"><%= t '.label_default_activity' %> </label>
<%= select_tag 'settings[default_activity]', options_from_collection_for_select( [['always_ask', t('.label_always_ask')], ['system', t('.label_system')]] + TimeEntryActivity.system.active.to_a.pluck(:id, :name), :first, :last, Stopwatch.settings['default_activity'] ) %>
</p>

View File

@@ -1,11 +1,16 @@
<%= javascript_tag do %>
window.stopwatch = window.initStopwatch({
currentTimerUrl: '<%= j current_stopwatch_timers_url %>',
hourFormat: '<%= j format_hours 0.0 %>'
hourFormat: '<%= j format_hours 0.0 %>',
locales: {
startTimer: '<%= j l :label_stopwatch_start %>',
stopTimer: '<%= j l :label_stopwatch_stop %>'
},
});
<% if User.current.logged? %>
window.stopwatch.highlightRunningTimer(
<%= raw Stopwatch::Timer.new(User.current).to_json %>
)
);
window.stopwatch.setProjectId('<%= j @project&.id.to_s %>');
<% end %>
<% end %>

View File

@@ -2,9 +2,9 @@
<% time_entry = @time_entries.first %>
<li>
<% if User.current.is_running_timer? time_entry %>
<%= context_menu_link l(:label_stopwatch_stop), stop_stopwatch_timer_path(time_entry, context: '1'), class: 'icon icon-time', remote: true, method: :put %>
<%= context_menu_link sprite_icon(:time, l(:label_stopwatch_stop)), stop_stopwatch_timer_path(time_entry, context: '1'), class: 'icon', remote: true, method: :put %>
<% else %>
<%= context_menu_link l(:label_stopwatch_start), start_stopwatch_timer_path(time_entry, context: '1'), class: 'icon icon-time', remote: true, method: :put %>
<%= context_menu_link sprite_icon(:time, l(:label_stopwatch_start)), start_stopwatch_timer_path(time_entry, context: '1'), class: 'icon', remote: true, method: :put %>
<% end %>
</li>
<% end %>

View File

@@ -0,0 +1,20 @@
<h3 class="title"><%= l(:button_log_time) %> - <%= format_date User.current.today %> - <%= "#{@issue.tracker} #{@issue.id}: #{truncate @issue.subject}" %></h3>
<%= labelled_form_for @time_entry, url: stopwatch_timers_path, method: :post, remote: true do |f| %>
<% @time_entry.hours ||= 0 %>
<%= f.hidden_field :issue_id, value: @issue.id %>
<fieldset class="box tabular">
<legend><%= t 'stopwatch_timers.entry_form.legend_new' %></legend>
<p><%= f.select :activity_id, activity_collection_for_select_options(@time_entry), :required => true %></p>
<p><%= f.text_field :comments, :size => 100, :maxlength => 1024, :required => Setting.timelog_required_fields.include?('comments') %></p>
<% @time_entry.custom_field_values.each do |value| %>
<p><%= custom_field_tag_with_label :time_entry, value %></p>
<% end %>
</fieldset>
<p class="buttons">
<%= submit_tag l(:button_create) %>
</p>
<% end %>

View File

@@ -0,0 +1,9 @@
<% if @started_time_entry %>
contextMenuHide();
window.stopwatch.timerStarted(
<%= raw Stopwatch::Timer.new(User.current).to_json %>
);
<% else %>
$('#ajax-modal').html('<%= j render partial: 'new' %>');
showModal('ajax-modal', '700px');
<% end %>

View File

@@ -0,0 +1,6 @@
contextMenuHide();
window.stopwatch.updateStartStopLink(
'#stopwatch_stop_timer_<%= @issue.id %>',
'<%= j Stopwatch::IssueLinks.new(@issue, self).start_timer %>'
);
window.stopwatch.timerStopped();

View File

@@ -17,11 +17,11 @@
<td class="hours"><%= html_hours(format_hours(entry.hours)) %></td>
<td class="buttons">
<% if running %>
<%= link_to t(:label_stopwatch_stop), stop_stopwatch_timer_path(entry), remote: true, class: "icon-only icon-time", method: :put %>
<%= link_to sprite_icon(:time, t(:label_stopwatch_stop)), stop_stopwatch_timer_path(entry), remote: true, class: "icon-only", method: :put %>
<% else %>
<%= link_to t(:label_stopwatch_start), start_stopwatch_timer_path(entry), remote: true, class: "icon-only icon-time", method: :put %>
<%= link_to sprite_icon(:time, t(:label_stopwatch_start)), start_stopwatch_timer_path(entry), remote: true, class: "icon-only", method: :put %>
<% end %>
<%= link_to l(:button_edit), edit_stopwatch_timer_path(entry), remote: true, class: "icon-only icon-edit" if entry.editable_by? User.current %>
<%= link_to sprite_icon(:edit, l(:button_edit)), edit_stopwatch_timer_path(entry), remote: true, class: "icon-only" if entry.editable_by? User.current %>
</td>
</tr>
<% end -%>

View File

@@ -2,16 +2,19 @@
<fieldset class="box tabular">
<legend><%= t @time_entry.new_record? ? '.legend_new' : '.legend_edit' %></legend>
<p><%= f.select :activity_id, activity_collection_for_select_options(@time_entry), :required => true %></p>
<p><%= f.select :project_id, project_tree_options_for_select(Project.allowed_to(:log_time).to_a, :selected => @time_entry.project, :include_blank => true), :required => true %></p>
<p>
<%= f.text_field :issue_id, :size => 6, :required => Setting.timelog_required_fields.include?('issue_id') %>
<span id="time_entry_issue">
<p><label for="stopwatch_time_entry_project_id"><%= l :field_project %><span class="required"> *</span></label><%= f.select :project_id, project_tree_options_for_select(Project.allowed_to(:log_time).to_a, :selected => @time_entry.project, :include_blank => true), { no_label: true }, id: 'stopwatch_time_entry_project_id' %></p>
<p><label for="stopwatch_time_entry_issue_id"><%= l :field_issue %>
<% if Setting.timelog_required_fields.include?('issue_id') %>
<span class="required"> *</span>
<% end %>
</label>
<%= f.text_field :issue_id, :size => 6, no_label: true, id: 'stopwatch_time_entry_issue_id' %>
<span id="stopwatch_time_entry_issue">
<%= link_to_issue(@time_entry.issue) if @time_entry.issue.try(:visible?) %>
</span>
</p>
<p><label for="stopwatch_time_entry_activity_id"><%= l :field_activity %><span class="required"> *</span></label><%= f.select :activity_id, activity_collection_for_select_options(@time_entry), { no_label: true }, id: 'stopwatch_time_entry_activity_id' %></p>
<!--
<p><%= f.date_field :spent_on, :size => 10, :required => true %><%= calendar_for('time_entry_spent_on') %></p>
-->
@@ -25,19 +28,19 @@
<%= javascript_tag do %>
$(document).ready(function(){
$('#time_entry_project_id').change(function(){
$('#time_entry_issue_id').val('');
$('#stopwatch_time_entry_project_id').change(function(){
$('#stopwatch_time_entry_issue_id').val('');
});
$('#time_entry_project_id, #time_entry_issue_id').change(function(){
$('#stopwatch_time_entry_project_id, #stopwatch_time_entry_issue_id').change(function(){
$.ajax({
url: '<%= escape_javascript(@time_entry.new_record? ? new_time_entry_path(format: 'js') : edit_time_entry_path(@time_entry, format: 'js')) %>',
url: '<%= j update_form_stopwatch_timers_path(time_entry_id: @time_entry.id, format: 'js') %>',
type: 'post',
data: $(this).closest('form').serialize()
});
});
});
observeAutocompleteField('time_entry_issue_id',
observeAutocompleteField('stopwatch_time_entry_issue_id',
function(request, callback) {
var url = '<%= j auto_complete_issues_path %>';
var data = {
@@ -47,7 +50,7 @@
<% if @time_entry.new_record? && @project %>
project_id = '<%= @project.id %>';
<% else %>
project_id = $('#time_entry_project_id').val();
project_id = $('#stopwatch_time_entry_project_id').val();
<% end %>
if(project_id){
data['project_id'] = project_id;
@@ -65,8 +68,8 @@
},
{
select: function(event, ui) {
$('#time_entry_issue').text('');
$('#time_entry_issue_id').val(ui.item.value).change();
$('#stopwatch_time_entry_issue').text('');
$('#stopwatch_time_entry_issue_id').val(ui.item.value).change();
}
}
);

View File

@@ -1,10 +1,12 @@
<h3 class="title"><%= l(:button_log_time) %> - <%= format_date User.current.today %></h3>
<%= render partial: 'entries_list', locals: { entries: @entries } %>
<% if @entries %>
<%= render partial: 'stopwatch_timers/entries_list', locals: { entries: @entries } %>
<% end %>
<%= labelled_form_for @time_entry, url: stopwatch_timers_path, method: :post, remote: true do |f| %>
<% @time_entry.hours ||= 0 %>
<%= render partial: 'entry_form', locals: { f: f } %>
<%= render partial: 'stopwatch_timers/entry_form', locals: { f: f } %>
<p class="buttons">
<%= submit_tag l(:button_create) %>

View File

@@ -1,4 +1,5 @@
hideModal();
window.stopwatch.highlightRunningTimer(
window.stopwatch.timerStarted(
<%= raw Stopwatch::Timer.new(User.current).to_json %>
);

View File

@@ -7,9 +7,10 @@
<% end %>
<% if @started_time_entry %>
window.stopwatch.timerStarted('<%= @started_time_entry.id %>',
'<%= j format_hours @started_time_entry.hours %>');
window.stopwatch.timerStarted(
<%= raw Stopwatch::Timer.new(User.current).to_json %>
);
<% else %>
window.stopwatch.timerStopped();
window.stopwatch.timerStopped();
<% end %>

View File

@@ -0,0 +1,5 @@
$('#stopwatch_time_entry_activity_id').html('<%= escape_javascript options_for_select(activity_collection_for_select_options(@time_entry), @time_entry.activity_id) %>');
$('#stopwatch_time_entry_issue').html('<%= escape_javascript link_to_issue(@time_entry.issue) if @time_entry.issue.try(:visible?) %>');
$('#stopwatch_time_entry_project_id').html('<%= escape_javascript project_tree_options_for_select(Project.allowed_to(:log_time).to_a, :selected => @time_entry.project, :include_blank => true) %>');

View File

@@ -1,6 +1,7 @@
window.initStopwatch = function(config){
var currentTimerUrl = config.currentTimerUrl;
var hourFormat = config.hourFormat;
var locales = config.locales;
var hoursRe = hourFormat.replace(/0+/g, '\\d+').replace(/\./g, '\\.');
var titleRegexp = new RegExp('^(' + hoursRe + ' - )?(.*)$');
@@ -68,13 +69,48 @@ window.initStopwatch = function(config){
highlightRunningTimer: highlightRunningTimer,
timerStopped: function(){
highlightRunningTimer();
},
timerStarted: function(entryId, spentTime){
highlightRunningTimer({
running: true,
time_entry_id: entryId,
time_spent: spentTime
// fix up any issue timer start/stop links in the UI
// no running timer -> all links will start a timer
$('a.stopwatch_issue_timer').each(function(){
var a = $(this);
a.attr('href', a.attr('href').replace(/stop$/, 'start'));
a.find('span').text(locales.startTimer);
});
},
timerStarted: function(data){
highlightRunningTimer(data);
// {
// running: true,
// time_entry_id: entryId,
// time_spent: spentTime
// });
// fix up any issue timer start/stop links in the UI
// all links will start a timer, except the one for the current issue,
// which has to be turned into a stop link.
if(data.running) {
$('a.stopwatch_issue_timer').each(function(){
var a = $(this);
var href = a.attr('href');
if(data.issue_id) {
if(a.data('issueId') == data.issue_id) {
a.attr('href', href.replace(/start$/, 'stop'));
a.find('span').text(locales.stopTimer);
} else {
a.attr('href', href.replace(/stop$/, 'start'));
a.find('span').text(locales.startTimer);
}
}
});
}
},
updateStartStopLink: function(id, replacement){
$(id).replaceWith(function(){
return $(replacement, { html: $(this).html() });
});
},
setProjectId: function(projectId){
var a = $('a#stopwatch-menu');
a.attr('href', a.attr('href').replace(/\/new.*$/, '/new?project_id='+projectId));
}
});
};

View File

@@ -1,6 +1,11 @@
en:
label_stopwatch_start: Start tracking
label_stopwatch_stop: Stop tracking
stopwatch:
settings:
label_always_ask: 'Always ask'
label_default_activity: 'Default activity for "Start tracking"'
label_system: 'Use system default'
stopwatch_timers:
entries_list:
button_stop: Stop

View File

@@ -1,6 +1,7 @@
resources :stopwatch_timers, only: %i(new create edit update) do
collection do
get :current
post :update_form
end
member do
put :start
@@ -8,3 +9,8 @@ resources :stopwatch_timers, only: %i(new create edit update) do
end
end
scope 'issues/:issue_id' do
post 'timer/start', to: 'stopwatch_issue_timers#start', as: :start_issue_timer
post 'timer/stop', to: 'stopwatch_issue_timers#stop', as: :stop_issue_timer
end

14
init.rb
View File

@@ -1,13 +1,14 @@
require 'stopwatch/hooks'
Redmine::Plugin.register :stopwatch do
name 'Redmine Stopwatch Plugin'
author 'Jens Krämer'
author_url 'https://jkraemer.net/'
description "Start/stop timer and quick access to today's time bookings for Redmine"
version '0.1.0'
version '1.1.0'
requires_redmine version_or_higher: '3.4.0'
requires_redmine version_or_higher: '6.0.0'
settings default: {
'default_activity' => 'always_ask',
}, partial: 'stopwatch/settings'
menu :account_menu, :stopwatch,
:new_stopwatch_timer_path,
@@ -17,7 +18,4 @@ Redmine::Plugin.register :stopwatch do
if: ->(*_){ User.current.logged? and User.current.allowed_to?(:log_time, nil, global: true) }
end
Rails.configuration.to_prepare do
Stopwatch::UserPatch.apply
end
Stopwatch.setup

View File

@@ -1,2 +1,45 @@
# frozen_string_literal: true
module Stopwatch
def self.setup
Stopwatch::ContextMenusControllerPatch.apply
Stopwatch::IssuesControllerPatch.apply
::TimeEntry.prepend Stopwatch::TimeEntryPatch
::User.prepend Stopwatch::UserPatch
Stopwatch::Hooks # just load it
end
def self.settings
Setting.plugin_stopwatch
end
def self.default_activity
if id = settings['default_activity'].presence
if id.to_s =~ /^\d+$/
TimeEntryActivity.find_by_id id
else
id
end
end
end
def self.default_activity_for(time_entry)
default = Stopwatch.default_activity
return nil if default == 'always_ask'
project = time_entry.project || time_entry.issue&.project
if project.nil?
activities = TimeEntryActivity.shared.active
else
activities = project.activities
end
if default == 'system'
activities.detect(&:is_default?) || activities.detect{|a| a.parent&.is_default?} || (activities.one? && activities[0]).presence
else
return activities.detect{ |a| a == default || a.parent == default }
end
end
end

View File

@@ -0,0 +1,28 @@
# frozen_string_literal: true
module Stopwatch
module ContextMenusControllerPatch
module Helper
def watcher_link(objects, user)
link = +''
if params[:action] == 'issues' and
objects.one? and (issue = objects[0]).is_a?(Issue) and
User.current.allowed_to?(:log_time, issue.project)
t = Stopwatch::IssueTimer.new(issue: issue)
if t.running?
link << IssueLinks.new(issue, self).stop_timer
else
link << IssueLinks.new(issue, self).start_timer
end
end
super + content_tag(:li, link.html_safe)
end
end
def self.apply
ContextMenusController.class_eval do
helper Helper
end
end
end
end

View File

@@ -0,0 +1,28 @@
module Stopwatch
class IssueLinks < Struct.new(:issue, :context)
def start_timer
context.link_to(
context.sprite_icon(:time, I18n.t(:label_stopwatch_start)),
context.start_issue_timer_path(issue),
class: 'icon stopwatch_issue_timer',
data: { issue_id: issue.id },
remote: true,
method: 'post'
)
end
def stop_timer
context.link_to(
context.sprite_icon(:time, I18n.t(:label_stopwatch_stop)),
context.stop_issue_timer_path(issue),
class: 'icon stopwatch_issue_timer',
data: { issue_id: issue.id },
remote: true,
method: 'post'
)
end
# to make route helpers happy
def controller; nil end
end
end

View File

@@ -0,0 +1,18 @@
module Stopwatch
class IssueTimer
def initialize(issue:, user: User.current)
@issue = issue
@user = user
end
def running?
running_time_entry.present?
end
def running_time_entry
@running_time_entry ||= @issue.time_entries.find_by_id(@user.running_time_entry_id)
end
end
end

View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
module Stopwatch
module IssuesControllerPatch
module Helper
def watcher_link(issue, user)
link = +''
if User.current.allowed_to?(:log_time, issue.project)
t = Stopwatch::IssueTimer.new(issue: issue)
if t.running?
link << IssueLinks.new(issue, self).stop_timer
else
link << IssueLinks.new(issue, self).start_timer
end
end
link.html_safe + super
end
end
def self.apply
IssuesController.class_eval do
helper Helper
end
end
end
end

View File

@@ -1,7 +1,7 @@
module Stopwatch
class StartTimer
Result = ImmutableStruct.new(:success?, :error)
Result = ImmutableStruct.new(:success?, :error, :started)
def initialize(time_entry, user: User.current)
@time_entry = time_entry
@@ -13,7 +13,6 @@ module Stopwatch
return Result.new(error: :unauthorized)
end
StopTimer.new(user: @user).call
@time_entry.hours = 0 if @time_entry.hours.nil?
# we want to start tracking time if this is an existing time entry, or a
@@ -21,9 +20,13 @@ module Stopwatch
# new entries with hours > 0 are just saved as is.
start_timer = !@time_entry.new_record? || @time_entry.hours == 0
# stop currently running timer, but only when there is a chance for us
# to succeed creating the new one.
StopTimer.new(user: @user).call if @time_entry.valid?
if @time_entry.save
start_new_timer if start_timer
return Result.new(success: true)
return Result.new(success: true, started: start_timer)
else
Rails.logger.error("could not save time entry: \n#{@time_entry.errors.inspect}")
return Result.new(error: :invalid)

View File

@@ -0,0 +1,14 @@
module Stopwatch
module TimeEntryPatch
extend ActiveSupport::Concern
prepended do
before_destroy :stop_timer
end
def stop_timer
t = Stopwatch::Timer.new(user)
t.update(stop: true) if t.running?
end
end
end

View File

@@ -45,7 +45,7 @@ module Stopwatch
# timestamp to either nil (stop: true) or current time.
def update(stop: false)
if hours = runtime_hours and entry = self.time_entry
time_entry.update_column :hours, entry.hours + hours
time_entry.update_column :hours, entry.read_attribute(:hours) + hours
end
data[:started_at] = stop || !running? ? nil : Time.now.to_i
save
@@ -65,7 +65,8 @@ module Stopwatch
time_entry_id: time_entry_id,
time_spent: formatter.format_hours,
html_time_spent: formatter.html_hours,
running: running?
running: running?,
issue_id: time_entry&.issue_id
}.to_json
end

View File

@@ -1,16 +1,24 @@
module Stopwatch
module UserPatch
def self.apply
User.prepend self unless User < self
end
extend ActiveSupport::Concern
def timer_running?
Stopwatch::Timer.new(self).running?
end
def is_running_timer?(time_entry)
id = running_time_entry_id
id.present? and time_entry.id == id
end
def running_time_entry_id
timer = Stopwatch::Timer.new(self)
timer.running? and timer.time_entry_id == time_entry.id
timer.time_entry_id if timer.running?
end
def todays_time_entry_for(issue)
TimeEntry.order(created_on: :desc).
find_or_initialize_by(user: self, issue: issue, spent_on: today)
end
end
end

View File

@@ -0,0 +1,118 @@
require File.expand_path('../test_helper', __dir__)
class TicketTimerTest < Redmine::IntegrationTest
include ActiveJob::TestHelper
fixtures :projects,
:users, :email_addresses,
:roles,
:members,
:member_roles,
:trackers,
:projects_trackers,
:enabled_modules,
:issue_statuses,
:issues,
:enumerations,
:custom_fields,
:custom_values,
:custom_fields_trackers,
:attachments
setup do
@issue = Issue.find 1
@user = User.find_by_login 'jsmith'
end
test "should create / stop / resume timer for ticket" do
log_user 'jsmith', 'jsmith'
assert_not_running
get "/issues/1"
assert_response :success
assert_select "div.contextual a", text: /start tracking/i
assert_no_difference ->{TimeEntry.count} do
post "/issues/1/timer/start", xhr: true
assert_response :success
end
assert_not_running
assert_difference ->{TimeEntry.count} do
post "/stopwatch_timers", xhr: true, params: {
time_entry: { issue_id: 1, activity_id: 9 }
}
assert_response :success
end
assert_running
get "/issues/1"
assert_select "div.contextual a", text: /stop tracking/i
assert_no_difference ->{TimeEntry.count} do
post "/issues/1/timer/stop", xhr: true
end
assert_not_running
get "/issues/1"
assert_select "div.contextual a", text: /start tracking/i
assert_no_difference ->{TimeEntry.count} do
post "/issues/1/timer/start", xhr: true
end
assert_response :success
assert_running
get "/issues/1"
assert_select "div.contextual a", text: /stop tracking/i
assert_no_difference ->{TimeEntry.count} do
post "/issues/1/timer/stop", xhr: true
end
assert_not_running
end
test "should ask by default" do
log_user 'jsmith', 'jsmith'
TimeEntry.delete_all
assert_no_difference ->{TimeEntry.count} do
post "/issues/1/timer/start", xhr: true
assert_response :ok
end
end
test "should use global default actvity" do
log_user 'jsmith', 'jsmith'
TimeEntry.delete_all
with_settings plugin_stopwatch: { 'default_activity' => 'system'} do
post "/issues/1/timer/start", xhr: true
assert_response :created
end
assert te = TimeEntry.last
assert_equal 1, te.issue_id
assert_equal 10, te.activity_id
end
test "should use configured default actvity" do
log_user 'jsmith', 'jsmith'
TimeEntry.delete_all
with_settings plugin_stopwatch: { 'default_activity' => '9'} do
post "/issues/1/timer/start", xhr: true
assert_response :created
end
assert te = TimeEntry.last
assert_equal 1, te.issue_id
assert_equal 9, te.activity_id
end
private
def assert_not_running
assert_not Stopwatch::Timer.new(User.find(@user.id)).running?
end
def assert_running
assert Stopwatch::Timer.new(User.find(@user.id)).running?
end
end

View File

@@ -1,5 +1,2 @@
require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper')
class ActiveSupport::TestCase
end

View File

@@ -1,4 +1,4 @@
require_relative '../test_helper'
require File.expand_path('../../test_helper', __FILE__)
class StartTimerTest < ActiveSupport::TestCase
fixtures :users, :user_preferences, :time_entries, :projects,
@@ -29,7 +29,7 @@ class StartTimerTest < ActiveSupport::TestCase
@time_entry.reload
assert_equal hours, @time_entry.hours
another = TimeEntry.new(@time_entry.attributes)
another = TimeEntry.new(@time_entry.attributes.except('id'))
another.user = @user
r = Stopwatch::StartTimer.new(another, user: @user).call
assert r.success?

View File

@@ -1,4 +1,4 @@
require_relative '../test_helper'
require File.expand_path('../../test_helper', __FILE__)
class StopTimerTest < ActiveSupport::TestCase
fixtures :users, :user_preferences, :time_entries, :projects,

View File

@@ -0,0 +1,78 @@
require File.expand_path('../../test_helper', __FILE__)
class StopwatchTest < ActiveSupport::TestCase
fixtures :projects, :enabled_modules, :enumerations
setup do
@project = Project.find 'ecookbook'
@te = TimeEntry.new project: @project
end
test "default value for default_activity" do
assert_equal 'always_ask', Stopwatch.default_activity
end
test "should find default activity" do
with_settings plugin_stopwatch: { 'default_activity' => 9} do
assert_equal 'Design', Stopwatch.default_activity.name
end
with_settings plugin_stopwatch: { 'default_activity' => '11'} do
assert_equal 'QA', Stopwatch.default_activity.name
end
end
### 'system' -> ask if there is no sys default / more than one availabale activity
test "should use system default activity for te" do
with_settings plugin_stopwatch: { 'default_activity' => 'system' } do
assert_equal 'Development', Stopwatch.default_activity_for(@te).name
end
end
test "should use single active project activity for time entry" do
with_settings plugin_stopwatch: { 'default_activity' => 'system' } do
TimeEntryActivity.create! active: false, project: @project, name: 'Development', parent_id: 10
TimeEntryActivity.create! active: false, project: @project, name: 'QA', parent_id: 11
assert_equal 'Design', Stopwatch.default_activity_for(@te).name
end
end
test "should ask if more than one and no default" do
with_settings plugin_stopwatch: { 'default_activity' => 'system' } do
TimeEntryActivity.update_all is_default: false
assert_nil Stopwatch.default_activity_for(@te)
end
end
### always_ask (the default)
test "should return nil if always ask is set" do
assert_nil Stopwatch.default_activity_for(@te)
TimeEntryActivity.create! active: false, project: @project, name: 'Development'
assert_nil Stopwatch.default_activity_for(@te)
TimeEntryActivity.where(name: 'Development').delete_all
assert_nil Stopwatch.default_activity_for(@te)
end
### specific activity -> ask only if this is not available in project
test "should use configured default activity for time entry" do
with_settings plugin_stopwatch: { 'default_activity' => '9' } do
assert_equal 'Design', Stopwatch.default_activity_for(@te).name
end
end
test "should not have default if unavailable" do
TimeEntryActivity.create! active: false, project: @project, name: 'Design', parent_id: 9
TimeEntryActivity.create! active: false, project: @project, name: 'Development', parent_id: 11
assert_equal [10], @project.activities.pluck(:id)
with_settings plugin_stopwatch: { 'default_activity' => '9'} do
assert_nil Stopwatch.default_activity_for(@te)
end
end
end

View File

@@ -0,0 +1,24 @@
require File.expand_path('../../test_helper', __FILE__)
class StartTimerTest < ActiveSupport::TestCase
fixtures :users, :user_preferences, :time_entries, :projects,
:roles, :member_roles, :members, :enumerations, :enabled_modules
setup do
@user = User.find 1
@time_entry = TimeEntry.where(user_id: 1).first
# so we dont have to load all the issue and related fixtures:
@time_entry.update_column :issue_id, nil
end
test "should stop timer before destroy" do
assert r = Stopwatch::StartTimer.new(@time_entry, user: @user).call
assert r.success?, r.inspect
assert User.find(@user.id).timer_running?
@time_entry.destroy
refute User.find(@user.id).timer_running?
end
end

View File

@@ -1,4 +1,4 @@
require_relative '../test_helper'
require File.expand_path('../../test_helper', __FILE__)
class TimerTest < ActiveSupport::TestCase
fixtures :users, :user_preferences, :time_entries

View File

@@ -1,7 +1,7 @@
require_relative '../test_helper'
require File.expand_path('../../test_helper', __FILE__)
class UserTest < ActiveSupport::TestCase
fixtures :users, :user_preferences
fixtures :users, :user_preferences, :issues
setup do
@user = User.find 1
@@ -18,4 +18,13 @@ class UserTest < ActiveSupport::TestCase
assert @user.timer_running?
end
test "should build time entry for issue" do
i = Issue.find 1
te = @user.todays_time_entry_for i
assert te.new_record?
assert_equal @user, te.user
assert_equal i, te.issue
assert_equal @user.today, te.spent_on
end
end