From 8bc41e2cb05217b103de28c2cba9741f6e226a5b Mon Sep 17 00:00:00 2001 From: Reinier Balt Date: Sat, 19 Jul 2008 20:27:45 +0200 Subject: [PATCH] add recurring todos to tracks --- README | 2 +- app/controllers/application.rb | 92 +- app/controllers/recurring_todos_controller.rb | 237 ++++ app/controllers/todos_controller.rb | 23 +- app/helpers/recurring_todos_helper.rb | 73 ++ app/helpers/todos_helper.rb | 18 +- app/models/recurring_todo.rb | 517 ++++++++ app/models/tag.rb | 2 +- app/models/todo.rb | 5 + app/models/user.rb | 3 + app/views/layouts/standard.html.erb | 2 + app/views/recurring_todos/_edit_form.html.erb | 147 +++ .../recurring_todos/_recurring_todo.html.erb | 12 + .../recurring_todos/_recurring_todo_form.erb | 145 ++ app/views/recurring_todos/create.js.rjs | 20 + app/views/recurring_todos/destroy.js.rjs | 12 + app/views/recurring_todos/edit.js.rjs | 4 + app/views/recurring_todos/index.html.erb | 42 + app/views/recurring_todos/new.html.erb | 1 + app/views/recurring_todos/show.html.erb | 2 + app/views/recurring_todos/toggle_check.js.rjs | 30 + app/views/recurring_todos/toggle_star.js.rjs | 3 + app/views/recurring_todos/update.js.rjs | 22 + app/views/todos/_todo.html.erb | 1 + app/views/todos/create.js.rjs | 2 +- app/views/todos/destroy.js.rjs | 16 +- app/views/todos/toggle_check.js.rjs | 23 +- app/views/todos/update.js.rjs | 8 +- config/routes.rb | 4 + db/migrate/039_create_recurring_todos.rb | 52 + public/images/add.png | Bin 0 -> 596 bytes public/images/recurring16x16.png | Bin 0 -> 410 bytes public/images/recurring24x24.png | Bin 0 -> 598 bytes public/images/recurring_menu16x16.png | Bin 0 -> 475 bytes public/images/recurring_menu24x24.png | Bin 0 -> 814 bytes public/images/trans70.png | Bin 0 -> 328 bytes public/javascripts/application.js | 123 +- public/stylesheets/standard.css | 1165 +++++++++-------- test/fixtures/recurring_todos.yml | 129 ++ .../recurring_todos_controller_test.rb | 22 + test/unit/recurring_todo_test.rb | 249 ++++ 41 files changed, 2576 insertions(+), 632 deletions(-) create mode 100644 app/controllers/recurring_todos_controller.rb create mode 100644 app/helpers/recurring_todos_helper.rb create mode 100644 app/models/recurring_todo.rb create mode 100644 app/views/recurring_todos/_edit_form.html.erb create mode 100644 app/views/recurring_todos/_recurring_todo.html.erb create mode 100644 app/views/recurring_todos/_recurring_todo_form.erb create mode 100644 app/views/recurring_todos/create.js.rjs create mode 100644 app/views/recurring_todos/destroy.js.rjs create mode 100644 app/views/recurring_todos/edit.js.rjs create mode 100644 app/views/recurring_todos/index.html.erb create mode 100644 app/views/recurring_todos/new.html.erb create mode 100644 app/views/recurring_todos/show.html.erb create mode 100644 app/views/recurring_todos/toggle_check.js.rjs create mode 100644 app/views/recurring_todos/toggle_star.js.rjs create mode 100644 app/views/recurring_todos/update.js.rjs create mode 100644 db/migrate/039_create_recurring_todos.rb create mode 100644 public/images/add.png create mode 100644 public/images/recurring16x16.png create mode 100644 public/images/recurring24x24.png create mode 100644 public/images/recurring_menu16x16.png create mode 100644 public/images/recurring_menu24x24.png create mode 100644 public/images/trans70.png create mode 100644 test/fixtures/recurring_todos.yml create mode 100644 test/functional/recurring_todos_controller_test.rb create mode 100644 test/unit/recurring_todo_test.rb diff --git a/README b/README index 3e6d8f9d..28cf4e46 100644 --- a/README +++ b/README @@ -20,4 +20,4 @@ For those upgrading, change notes are available in /doc/CHANGELOG. If you are th While fully usable for everyday use, Tracks is still a work in progress. Make sure that you take sensible precautions and back up all your data frequently, taking particular care when you are upgrading. -Enjoy being productive! \ No newline at end of file +Enjoy being productive! diff --git a/app/controllers/application.rb b/app/controllers/application.rb index 2cfba813..3d26cdd0 100644 --- a/app/controllers/application.rb +++ b/app/controllers/application.rb @@ -1,5 +1,6 @@ -# The filters added to this controller will be run for all controllers in the application. -# Likewise will all the methods added be available for all controllers. +# The filters added to this controller will be run for all controllers in the +# application. Likewise will all the methods added be available for all +# controllers. require_dependency "login_system" require_dependency "tracks/source_view" @@ -47,11 +48,12 @@ class ApplicationController < ActionController::Base # http://wiki.rubyonrails.com/rails/show/HowtoChangeSessionOptions unless session == nil return if @controller_name == 'feed' or session['noexpiry'] == "on" - # If the method is called by the feed controller (which we don't have under session control) - # or if we checked the box to keep logged in on login - # don't set the session expiry time. + # If the method is called by the feed controller (which we don't have + # under session control) or if we checked the box to keep logged in on + # login don't set the session expiry time. if session - # Get expiry time (allow ten seconds window for the case where we have none) + # Get expiry time (allow ten seconds window for the case where we have + # none) expiry_time = session['expiry_time'] || Time.now + 10 if expiry_time < Time.now # Too late, matey... bang goes your session! @@ -80,10 +82,10 @@ class ApplicationController < ActionController::Base # end # end - # Returns a count of next actions in the given context or project - # The result is count and a string descriptor, correctly pluralised if there are no + # Returns a count of next actions in the given context or project The result + # is count and a string descriptor, correctly pluralised if there are no # actions or multiple actions - # + # def count_undone_todos_phrase(todos_parent, string="actions") count = count_undone_todos(todos_parent) if count == 1 @@ -105,9 +107,9 @@ class ApplicationController < ActionController::Base count || 0 end - # Convert a date object to the format specified in the user's preferences - # in config/settings.yml - # + # Convert a date object to the format specified in the user's preferences in + # config/settings.yml + # def format_date(date) if date date_format = prefs.date_format @@ -118,10 +120,10 @@ class ApplicationController < ActionController::Base formatted_date end - # Uses RedCloth to transform text using either Textile or Markdown - # Need to require redcloth above - # RedCloth 3.0 or greater is needed to use Markdown, otherwise it only handles Textile - # + # Uses RedCloth to transform text using either Textile or Markdown Need to + # require redcloth above RedCloth 3.0 or greater is needed to use Markdown, + # otherwise it only handles Textile + # def markdown(text) RedCloth.new(text).to_html end @@ -130,21 +132,19 @@ class ApplicationController < ActionController::Base Hash[*projects.reject{ |p| p.default_context.nil? }.map{ |p| [p.name, p.default_context.name] }.flatten].to_json end - # Here's the concept behind this "mobile content negotiation" hack: - # In addition to the main, AJAXy Web UI, Tracks has a lightweight - # low-feature 'mobile' version designed to be suitablef or use - # from a phone or PDA. It makes some sense that tne pages of that - # mobile version are simply alternate representations of the same - # Todo resources. The implementation goal was to treat mobile - # as another format and be able to use respond_to to render both - # versions. Unfortunately, I ran into a lot of trouble simply - # registering a new mime type 'text/html' with format :m because - # :html already is linked to that mime type and the new - # registration was forcing all html requests to be rendered in - # the mobile view. The before_filter and after_filter hackery - # below accomplishs that implementation goal by using a 'fake' - # mime type during the processing and then setting it to - # 'text/html' in an 'after_filter' -LKM 2007-04-01 + # Here's the concept behind this "mobile content negotiation" hack: In + # addition to the main, AJAXy Web UI, Tracks has a lightweight low-feature + # 'mobile' version designed to be suitablef or use from a phone or PDA. It + # makes some sense that tne pages of that mobile version are simply alternate + # representations of the same Todo resources. The implementation goal was to + # treat mobile as another format and be able to use respond_to to render both + # versions. Unfortunately, I ran into a lot of trouble simply registering a + # new mime type 'text/html' with format :m because :html already is linked to + # that mime type and the new registration was forcing all html requests to be + # rendered in the mobile view. The before_filter and after_filter hackery + # below accomplishs that implementation goal by using a 'fake' mime type + # during the processing and then setting it to 'text/html' in an + # 'after_filter' -LKM 2007-04-01 def mobile? return params[:format] == 'm' || response.content_type == MOBILE_CONTENT_TYPE end @@ -220,9 +220,9 @@ class ApplicationController < ActionController::Base end end - # Set the contents of the flash message from a controller - # Usage: notify :warning, "This is the message" - # Sets the flash of type 'warning' to "This is the message" + # Set the contents of the flash message from a controller Usage: notify + # :warning, "This is the message" Sets the flash of type 'warning' to "This is + # the message" def notify(type, message) flash[type] = message logger.error("ERROR: #{message}") if type == :error @@ -231,5 +231,29 @@ class ApplicationController < ActionController::Base def set_time_zone Time.zone = current_user.prefs.time_zone if logged_in? end + + def create_todo_from_recurring_todo(rt, date=nil) + # create todo and initialize with data from recurring_todo rt + todo = current_user.todos.build( { :description => rt.description, :notes => rt.notes, :project_id => rt.project_id, :context_id => rt.context_id}) + + # set dates + todo.due = rt.get_due_date(date) + todo.show_from = rt.get_show_from_date(date) + todo.recurring_todo_id = rt.id + saved = todo.save + if saved + todo.tag_with(rt.tag_list, current_user) + todo.tags.reload + end + + # increate number of occurences created from recurring todo + rt.inc_occurences + + # mark recurring todo complete if there are no next actions left + checkdate = todo.due.nil? ? todo.show_from : todo.due + rt.toggle_completion! unless rt.has_next_todo(checkdate) + + return saved ? todo : nil + end end diff --git a/app/controllers/recurring_todos_controller.rb b/app/controllers/recurring_todos_controller.rb new file mode 100644 index 00000000..1f9d6939 --- /dev/null +++ b/app/controllers/recurring_todos_controller.rb @@ -0,0 +1,237 @@ +class RecurringTodosController < ApplicationController + + helper :todos, :recurring_todos + + append_before_filter :init, :only => [:index, :new, :edit] + append_before_filter :get_recurring_todo_from_param, :only => [:destroy, :toggle_check, :toggle_star, :edit, :update] + + def index + @recurring_todos = current_user.recurring_todos.find(:all, :conditions => ["state = ?", "active"]) + @completed_recurring_todos = current_user.recurring_todos.find(:all, :conditions => ["state = ?", "completed"]) + @no_recurring_todos = @recurring_todos.size == 0 + @no_completed_recurring_todos = @completed_recurring_todos.size == 0 + @count = @recurring_todos.size + end + + def new + end + + def show + end + + def edit + respond_to do |format| + format.js + end + end + + def update + @recurring_todo.tag_with(params[:tag_list], current_user) if params[:tag_list] + @original_item_context_id = @recurring_todo.context_id + @original_item_project_id = @recurring_todo.project_id + + # we needed to rename the recurring_period selector in the edit form + # because the form for a new recurring todo and the edit form are on the + # same page. + params['recurring_todo']['recurring_period']=params['recurring_edit_todo']['recurring_period'] + + # update project + if params['recurring_todo']['project_id'].blank? && !params['project_name'].nil? + if params['project_name'] == 'None' + project = Project.null_object + else + project = current_user.projects.find_by_name(params['project_name'].strip) + unless project + project = current_user.projects.build + project.name = params['project_name'].strip + project.save + @new_project_created = true + end + end + params["recurring_todo"]["project_id"] = project.id + end + + # update context + if params['recurring_todo']['context_id'].blank? && !params['context_name'].blank? + context = current_user.contexts.find_by_name(params['context_name'].strip) + unless context + context = current_user.contexts.build + context.name = params['context_name'].strip + context.save + @new_context_created = true + end + params["recurring_todo"]["context_id"] = context.id + end + + params["recurring_todo"]["weekly_return_monday"]=' ' if params["recurring_todo"]["weekly_return_monday"].nil? + params["recurring_todo"]["weekly_return_tuesday"]=' ' if params["recurring_todo"]["weekly_return_tuesday"].nil? + params["recurring_todo"]["weekly_return_wednesday"]=' ' if params["recurring_todo"]["weekly_return_wednesday"].nil? + params["recurring_todo"]["weekly_return_thursday"]=' ' if params["recurring_todo"]["weekly_return_thursday"].nil? + params["recurring_todo"]["weekly_return_friday"]=' ' if params["recurring_todo"]["weekly_return_friday"].nil? + params["recurring_todo"]["weekly_return_saturday"]=' ' if params["recurring_todo"]["weekly_return_saturday"].nil? + params["recurring_todo"]["weekly_return_sunday"]=' ' if params["recurring_todo"]["weekly_return_sunday"].nil? + + @saved = @recurring_todo.update_attributes params["recurring_todo"] + + respond_to do |format| + format.js + end + end + + def create + p = RecurringTodoCreateParamsHelper.new(params) + @recurring_todo = current_user.recurring_todos.build(p.attributes) + + if p.project_specified_by_name? + project = current_user.projects.find_or_create_by_name(p.project_name) + @new_project_created = project.new_record_before_save? + @recurring_todo.project_id = project.id + end + + if p.context_specified_by_name? + context = current_user.contexts.find_or_create_by_name(p.context_name) + @new_context_created = context.new_record_before_save? + @recurring_todo.context_id = context.id + end + + @recurring_saved = @recurring_todo.save + unless (@recurring_saved == false) || p.tag_list.blank? + @recurring_todo.tag_with(p.tag_list, current_user) + @recurring_todo.tags.reload + end + + if @recurring_saved + @message = "The recurring todo was saved" + @todo_saved = create_todo_from_recurring_todo(@recurring_todo).nil? == false + if @todo_saved + @message += " / created a new todo" + else + @message += " / did not create todo" + end + @count = current_user.recurring_todos.count(:all, :conditions => ["state = ?", "active"]) + else + @message = "Error saving recurring todo" + end + + respond_to do |format| + format.js + end + end + + def destroy + + # remove all references to this recurring todo + @todos = current_user.todos.find(:all, {:conditions => ["recurring_todo_id = ?", params[:id]]}) + @number_of_todos = @todos.size + @todos.each do |t| + t.recurring_todo_id = nil + t.save + end + + # delete the recurring todo + @saved = @recurring_todo.destroy + @remaining = current_user.recurring_todos.count(:all) + + respond_to do |format| + + format.html do + if @saved + notify :notice, "Successfully deleted recurring action", 2.0 + redirect_to :action => 'index' + else + notify :error, "Failed to delete the recurring action", 2.0 + redirect_to :action => 'index' + end + end + + format.js do + render + end + end + end + + def toggle_check + @saved = @recurring_todo.toggle_completion! + + @count = current_user.recurring_todos.count(:all, :conditions => ["state = ?", "active"]) + @remaining = @count + + if @recurring_todo.active? + @remaining = current_user.recurring_todos.count(:all, :conditions => ["state = ?", 'completed']) + + # from completed back to active -> check if there is an active todo + @active_todos = current_user.todos.count(:all, {:conditions => ["state = ? AND recurring_todo_id = ?", 'active',params[:id]]}) + # create todo if there is no active todo belonging to the activated + # recurring_todo + @new_recurring_todo = create_todo_from_recurring_todo(@recurring_todo) if @active_todos == 0 + end + + respond_to do |format| + format.js + end + end + + def toggle_star + @recurring_todo.toggle_star! + @saved = @recurring_todo.save! + respond_to do |format| + format.js + end + end + + class RecurringTodoCreateParamsHelper + + def initialize(params) + @params = params['request'] || params + @attributes = params['request'] && params['request']['recurring_todo'] || params['recurring_todo'] + end + + def attributes + @attributes + end + + def project_name + @params['project_name'].strip unless @params['project_name'].nil? + end + + def context_name + @params['context_name'].strip unless @params['context_name'].nil? + end + + def tag_list + @params['tag_list'] + end + + def project_specified_by_name? + return false unless @attributes['project_id'].blank? + return false if project_name.blank? + return false if project_name == 'None' + true + end + + def context_specified_by_name? + return false unless @attributes['context_id'].blank? + return false if context_name.blank? + true + end + + end + + private + + def init + @days_of_week = [ ['Sunday',0], ['Monday',1], ['Tuesday', 2], ['Wednesday',3], ['Thursday',4], ['Friday',5], ['Saturday',6]] + @months_of_year = [ + ['January',1], ['Februari',2], ['March', 3], ['April',4], ['May',5], ['June',6], + ['July',7], ['August',8], ['September',9], ['October', 10], ['November', 11], ['December',12]] + @xth_day = [['first',1],['second',2],['third',3],['fourth',4],['last',5]] + @projects = current_user.projects.find(:all, :include => [:default_context]) + @contexts = current_user.contexts.find(:all) + @default_project_context_name_map = build_default_project_context_name_map(@projects).to_json + end + + def get_recurring_todo_from_param + @recurring_todo = current_user.recurring_todos.find(params[:id]) + end + +end \ No newline at end of file diff --git a/app/controllers/todos_controller.rb b/app/controllers/todos_controller.rb index 69d71586..9448d70c 100644 --- a/app/controllers/todos_controller.rb +++ b/app/controllers/todos_controller.rb @@ -121,6 +121,10 @@ class TodosController < ApplicationController # def toggle_check @saved = @todo.toggle_completion! + + # check if this todo has a related recurring_todo. If so, create next todo + check_for_next_todo if @saved + respond_to do |format| format.js do if @saved @@ -235,6 +239,10 @@ class TodosController < ApplicationController @todo = get_todo_from_params @context_id = @todo.context_id @project_id = @todo.project_id + + # check if this todo has a related recurring_todo. If so, create next todo + check_for_next_todo + @saved = @todo.destroy respond_to do |format| @@ -643,6 +651,19 @@ class TodosController < ApplicationController def self.is_feed_request(req) ['rss','atom','txt','ics'].include?(req.parameters[:format]) end + + def check_for_next_todo + # check if this todo has a related recurring_todo. If so, create next todo + @new_recurring_todo = nil + @recurring_todo = nil + if @todo.from_recurring_todo? + @recurring_todo = current_user.recurring_todos.find(@todo.recurring_todo_id) + if @recurring_todo.active? && @recurring_todo.has_next_todo(@todo.due) + date = @todo.due >= Date.today() ? @todo.due : Date.today()-1.day + @new_recurring_todo = create_todo_from_recurring_todo(@recurring_todo, date) + end + end + end class FindConditionBuilder @@ -711,6 +732,6 @@ class TodosController < ApplicationController return false if context_name.blank? true end - + end end diff --git a/app/helpers/recurring_todos_helper.rb b/app/helpers/recurring_todos_helper.rb new file mode 100644 index 00000000..b346e7ea --- /dev/null +++ b/app/helpers/recurring_todos_helper.rb @@ -0,0 +1,73 @@ +module RecurringTodosHelper + + def recurrence_time_span(rt) + case rt.ends_on + when "no_end_date" + return "" + when "ends_on_number_of_times" + return "for "+rt.number_of_occurences.to_s + " times" + when "ends_on_end_date" + starts = rt.start_from.nil? ? "" : "from " + format_date(rt.start_from) + ends = rt.end_date.nil? ? "" : " until " + format_date(rt.end_date) + return starts+ends + else + raise Exception.new, "unknown recurrence time span selection (#{self.ends_on})" + end + end + + def recurrence_target(rt) + case rt.target + when 'due_date' + return "due" + when 'show_from_date' + return "show" + else + return "ERROR" + end + end + + def recurring_todo_tag_list + tags_except_starred = @recurring_todo.tags.reject{|t| t.name == Todo::STARRED_TAG_NAME} + tag_list = tags_except_starred.collect{|t| "" + + # link_to(t.name, :controller => "todos", :action => "tag", :id => + # t.name) + TODO: tag view for recurring_todos (yet?) + t.name + + ""}.join('') + "#{tag_list}" + end + + def recurring_todo_remote_delete_icon + str = link_to( image_tag_for_delete, + recurring_todo_path(@recurring_todo), :id => "delete_icon_"+@recurring_todo.id.to_s, + :class => "icon delete_icon", :title => "delete the recurring action '#{@recurring_todo.description}'") + set_behavior_for_delete_icon + str + end + + def recurring_todo_remote_star_icon + str = link_to( image_tag_for_star(@recurring_todo), + toggle_star_recurring_todo_path(@recurring_todo), + :class => "icon star_item", :title => "star the action '#{@recurring_todo.description}'") + set_behavior_for_star_icon + str + end + + def recurring_todo_remote_edit_icon + if !@recurring_todo.completed? + str = link_to( image_tag_for_edit(@recurring_todo), + edit_recurring_todo_path(@recurring_todo), + :class => "icon edit_icon") + set_behavior_for_edit_icon + else + str = '' + image_tag("blank.png") + " " + end + str + end + + def recurring_todo_remote_toggle_checkbox + str = check_box_tag('item_id', toggle_check_recurring_todo_path(@recurring_todo), @recurring_todo.completed?, :class => 'item-checkbox') + set_behavior_for_toggle_checkbox + str + end + +end \ No newline at end of file diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index c543bbb0..1e199962 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -47,7 +47,7 @@ module TodosHelper :prevent_default => true end - def remote_star_icon + def remote_star_icon str = link_to( image_tag_for_star(@todo), toggle_star_todo_path(@todo), :class => "icon star_item", :title => "star the action '#{@todo.description}'") @@ -66,7 +66,7 @@ module TodosHelper def remote_edit_icon if !@todo.completed? - str = link_to( image_tag_for_edit, + str = link_to( image_tag_for_edit(@todo), edit_todo_path(@todo), :class => "icon edit_icon") set_behavior_for_edit_icon @@ -205,12 +205,12 @@ module TodosHelper javascript_tag str end - def item_container_id + def item_container_id (todo) if source_view_is :project - return "p#{@todo.project_id}" if @todo.active? - return "tickler" if @todo.deferred? + return "p#{todo.project_id}" if todo.active? + return "tickler" if todo.deferred? end - return "c#{@todo.context_id}" + return "c#{todo.context_id}" end def should_show_new_item @@ -272,8 +272,8 @@ module TodosHelper image_tag("blank.png", :title =>"Delete action", :class=>"delete_item") end - def image_tag_for_edit - image_tag("blank.png", :title =>"Edit action", :class=>"edit_item", :id=> dom_id(@todo, 'edit_icon')) + def image_tag_for_edit(todo) + image_tag("blank.png", :title =>"Edit action", :class=>"edit_item", :id=> dom_id(todo, 'edit_icon')) end def image_tag_for_star(todo) @@ -281,4 +281,4 @@ module TodosHelper image_tag("blank.png", :title =>"Star action", :class => class_str) end -end \ No newline at end of file +end diff --git a/app/models/recurring_todo.rb b/app/models/recurring_todo.rb new file mode 100644 index 00000000..92b331f3 --- /dev/null +++ b/app/models/recurring_todo.rb @@ -0,0 +1,517 @@ +class RecurringTodo < ActiveRecord::Base + + belongs_to :context + belongs_to :project + belongs_to :user + + attr_protected :user + + acts_as_state_machine :initial => :active, :column => 'state' + + state :active, :enter => Proc.new { |t| + t[:show_from], t.completed_at = nil, nil + t.occurences_count = 0 + } + state :completed, :enter => Proc.new { |t| t.completed_at = Time.now.utc }, :exit => Proc.new { |t| t.completed_at = nil } + + validates_presence_of :description + validates_length_of :description, :maximum => 100 + validates_length_of :notes, :maximum => 60000, :allow_nil => true + + validates_presence_of :context + + event :complete do + transitions :to => :completed, :from => [:active] + end + + event :activate do + transitions :to => :active, :from => [:completed] + end + + # the following recurrence patterns can be stored: + # + # daily todos - recurrence_period = 'daily' + # every nth day - nth stored in every_other1 + # every work day - only_work_days = true + # tracks will choose between both options using only_work_days + # weekly todos - recurrence_period = 'weekly' + # every nth week on a specific day - + # nth stored in every_other1 and the specific day is stored in every_day + # monthly todos - recurrence_period = 'monthly' + # every day x of nth month - x stored in every_other1 and nth is stored in every_other2 + # the xth y-day of every nth month (the forth tuesday of every 2 months) - + # x stored in every_other3, y stored in every_count, nth stored in every_other2 + # choosing between both options is done on recurrence_selector where 0 is + # for first type and 1 for second type + # yearly todos - recurrence_period = 'yearly' + # every day x of month y - x is stored in every_other1, y is stored in every_other2 + # the x-th day y of month z (the forth tuesday of september) - + # x is stored in every_other3, y is stored in every_count, z is stored in every_other2 + # choosing between both options is done on recurrence_selector where 0 is + # for first type and 1 for second type + + # DAILY + + def daily_selector=(selector) + case selector + when 'daily_every_x_day' + self.only_work_days = false + when 'daily_every_work_day' + self.only_work_days = true + end + end + + def daily_every_x_days=(x) + if recurring_period=='daily' + self.every_other1 = x + end + end + + # WEEKLY + + def weekly_every_x_week=(x) + if recurring_period=='weekly' + self.every_other1 = x + end + end + + def switch_week_day (day, position) + if self.every_day.nil? + self.every_day=' ' + end + self.every_day = self.every_day[0,position] + day + self.every_day[position+1,self.every_day.length] + end + + def weekly_return_monday=(selector) + if recurring_period=='weekly' + switch_week_day(selector,1) + end + end + + def weekly_return_tuesday=(selector) + switch_week_day(selector,2) if recurring_period=='weekly' + end + + def weekly_return_wednesday=(selector) + switch_week_day(selector,3) if recurring_period=='weekly' + end + + def weekly_return_thursday=(selector) + switch_week_day(selector,4) if recurring_period=='weekly' + end + + def weekly_return_friday=(selector) + switch_week_day(selector,5) if recurring_period=='weekly' + end + + def weekly_return_saturday=(selector) + switch_week_day(selector,6) if recurring_period=='weekly' + end + + def weekly_return_sunday=(selector) + switch_week_day(selector,0) if recurring_period=='weekly' + end + + def on_xday(n) + unless self.every_day.nil? + return self.every_day[n,1] == ' ' ? false : true + else + return false + end + end + + def on_monday + return on_xday(1) + end + + def on_tuesday + return on_xday(2) + end + + def on_wednesday + return on_xday(3) + end + + def on_thursday + return on_xday(4) + end + + def on_friday + return on_xday(5) + end + + def on_saturday + return on_xday(6) + end + + def on_sunday + return on_xday(0) + end + + # MONTHLY + + def monthly_selector=(selector) + if recurring_period=='monthly' + self.recurrence_selector= (selector=='monthly_every_x_day')? 0 : 1 + end + # todo + end + + def monthly_every_x_day=(x) + if recurring_period=='monthly' + self.every_other1 = x + end + end + + def monthly_every_x_month=(x) + if recurring_period=='monthly' + self.every_other2 = x + end + end + + def monthly_every_xth_day=(x) + if recurring_period=='monthly' + self.every_other3 = x + end + end + + def monthly_day_of_week=(dow) + if recurring_period=='monthly' + self.every_count = dow + end + end + + # YEARLY + + def yearly_selector=(selector) + if recurring_period=='yearly' + self.recurrence_selector = (selector=='yearly_every_x_day') ? 0 : 1 + end + end + + def yearly_month_of_year=(moy) + if recurring_period=='yearly' + self.every_other2 = moy + end + end + + def yearly_every_x_day=(x) + if recurring_period=='yearly' + self.every_other1 = x + end + end + + def yearly_every_xth_day=(x) + if recurring_period=='yearly' + self.every_other3 = x + end + end + + def yearly_day_of_week=(dow) + if recurring_period=='yearly' + self.every_count=dow + end + end + + # target + + def recurring_target=(t) + self.target = t + end + + def recurring_show_days_before=(days) + self.show_from_delta=days + end + + def recurrence_pattern + case recurring_period + when 'daily' + if only_work_days + return "on work days" + else + if every_other1 > 1 + return "every #{every_other1} days" + else + return "every day" + end + end + when 'weekly' + if every_other1 > 1 + return "every #{every_other1} weeks" + else + return 'weekly' + end + when 'monthly' + if self.recurrence_selector == 0 + return "every month on day #{self.every_other1}" + else + return "every #{self.xth} #{self.day_of_week} of every #{self.every_other2} month#{self.every_other2>1?'s':''}" + end + when 'yearly' + if self.recurrence_selector == 0 + return "every year on #{self.month_of_year} #{self.every_other1}" + else + return "every year on the #{self.xth} #{self.day_of_week} of #{self.month_of_year}" + end + else + return 'unknown recurrence pattern: period unknown' + end + end + + def xth + xth_day = ['first','second','third','fourth','last'] + return self.every_other3.nil? ? '??' : xth_day[self.every_other3-1] + end + + def day_of_week + days_of_week = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'] + return (self.every_count.nil? ? '??' : days_of_week[self.every_count]) + end + + def month_of_year + months_of_year = ['January','Februari','March','April','May','June','July','August','September','October','November','December'] + return self.every_other2.nil? ? '??' : months_of_year[self.every_other2-1] + end + + def starred? + tags.any? {|tag| tag.name == Todo::STARRED_TAG_NAME} + end + + def get_due_date(previous) + case self.target + when 'due_date' + return get_next_date(previous) + when 'show_from' + # so leave due date empty + return nil + end + end + + def get_show_from_date(previous) + case self.target + when 'due_date' + # so set show from date relative to due date unless show_from_delta is + # zero / nil + return (self.show_from_delta == 0 || self.show_from_delta.nil?) ? nil : get_due_date(previous) - self.show_from_delta.days + when 'show_from_date' + # Leave due date empty + return get_next_date(previous) + else + raise Exception.new, "unexpected value of recurrence target '#{self.target}'" + end + end + + def get_next_date(previous) + case self.recurring_period + when 'daily' + return get_daily_date(previous) + when 'weekly' + return get_weekly_date(previous) + when 'monthly' + return get_monthly_date(previous) + when 'yearly' + return get_yearly_date(previous) + else + raise Exception.new, "unknown recurrence pattern: '#{self.recurring_period}'" + end + end + + def get_daily_date(previous) + # previous is the due date of the previous todo or it is the completed_at + # date when the completed_at date is after due_date (i.e. you did not make + # the due date in time) + # + # assumes self.recurring_period == 'daily' + if previous.nil? + start = self.start_from.nil? ? Time.now.utc : self.start_from + else + # use the next day + start = previous + 1.day + end + + if self.only_work_days + if start.wday() >= 1 && start.wday() <= 5 # 1=monday; 5=friday + return start + else + if start.wday() == 0 # sunday + return start + 1.day + else # saturday + return start + 2.day + end + end + else # every nth day; n = every_other1 + # if there was no previous todo, do not add n: the first todo starts on + # today or on start_from + return previous == nil ? start : start+every_other1.day-1.day + end + end + + def get_weekly_date(previous) + if previous == nil + start = self.start_from.nil? ? Time.now.utc : self.start_from + else + start = previous + 1.day + if start.wday() == 0 + # we went to a new week , go to the nth next week and find first match + # that week + start += self.every_other1.week + end + end + # check if there are any days left this week for the next todo + start.wday().upto 6 do |i| + return start + (i-start.wday()).days unless self.every_day[i,1] == ' ' + end + + # we did not find anything this week, so check the nth next, starting from + # sunday + start = start + self.every_other1.week - (start.wday()).days + + # check if there are any days left this week for the next todo + start.wday().upto 6 do |i| + return start + (i-start.wday()).days unless self.every_day[i,1] == ' ' + end + + raise Exception.new, "unable to find next weekly date (#{self.every_day})" + end + + def get_monthly_date(previous) + if previous.nil? + start = self.start_from.nil? ? Time.now.utc : self.start_from + else + start = previous + end + day = self.every_other1 + n = self.every_other2 + + case self.recurrence_selector + when 0 # specific day of the month + if start.mday >= day + # there is no next day n in this month, search in next month + start += n.months + # go back to day + end + return Time.utc(start.year, start.month, day) + + when 1 # relative weekday of a month + the_next = get_xth_day_of_month(self.every_other3, self.every_count, start.month, start.year) + if the_next.nil? || the_next < start + # the nth day is already passed in this month, go to next month and try + # again + the_next = the_next+n.months + # TODO: if there is still no match, start will be set to nil. if we ever + # support 5th day of the month, we need to handle this case + the_next = get_xth_day_of_month(self.every_other3, self.every_count, the_next.month, the_next.year) + end + return the_next + else + raise Exception.new, "unknown monthly recurrence selection (#{self.recurrence_selector})" + end + return nil + end + + def get_xth_day_of_month(x, weekday, month, year) + if x == 5 + # last -> count backwards + last_day = Time.utc(year, month, Time.days_in_month(month)) + while last_day.wday != weekday + last_day -= 1.day + end + return last_day + else + # 1-4th -> count upwards + start = Time.utc(year,month,1) + n = x + while n > 0 + while start.wday() != weekday + start+= 1.day + end + n -= 1 + start += 1.day unless n==0 + end + return start + end + end + + def get_yearly_date(previous) + if previous.nil? + start = self.start_from.nil? ? Time.now.utc : self.start_from + else + start = previous + end + + day = self.every_other1 + month = self.every_other2 + + case self.recurrence_selector + when 0 # specific day of a specific month + # if there is no next month n in this year, search in next year + if start.month >= month + start = Time.utc(start.year+1, month, 1) if start.day >= day + start = Time.utc(start.year, month, 1) if start.day <= day + end + return Time.utc(start.year, month, day) + + when 1 # relative weekday of a specific month + # if there is no next month n in this year, search in next year + the_next = start.month > month ? Time.utc(start.year+1, month, 1) : start + + # get the xth day of the month + the_next = get_xth_day_of_month(self.every_other3, self.every_count, month, the_next.year) + + # if the_next is before previous, we went back into the past, so try next + # year + the_next = get_xth_day_of_month(self.every_other3, self.every_count, month, start.year+1) if the_next <= start + + return the_next + else + raise Exception.new, "unknown monthly recurrence selection (#{self.recurrence_selector})" + end + return nil + end + + def has_next_todo(previous) + unless self.number_of_occurences.nil? + return self.occurences_count < self.number_of_occurences + else + if self.end_date.nil? + return true + else + case self.target + when 'due_date' + return get_due_date(previous) <= self.end_date + when 'show_from_date' + return get_show_from_date(previous) <= self.end_date + else + raise Exception.new, "unexpected value of recurrence target '#{self.target}'" + end + end + end + end + + def toggle_completion! + saved = false + if completed? + saved = activate! + else + saved = complete! + end + return saved + end + + def toggle_star! + if starred? + delete_tags Todo::STARRED_TAG_NAME + tags.reload + else + add_tag Todo::STARRED_TAG_NAME + tags.reload + end + starred? + end + + def inc_occurences + self.occurences_count += 1 + self.save + end + +end diff --git a/app/models/tag.rb b/app/models/tag.rb index d3105eeb..81684c13 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,6 +1,6 @@ class Tag < ActiveRecord::Base has_many_polymorphs :taggables, - :from => [:todos], + :from => [:todos, :recurring_todos], :through => :taggings, :dependent => :destroy diff --git a/app/models/todo.rb b/app/models/todo.rb index cb820a7b..7a6f3892 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -3,6 +3,7 @@ class Todo < ActiveRecord::Base belongs_to :context belongs_to :project belongs_to :user + belongs_to :recurring_todo STARRED_TAG_NAME = "starred" @@ -120,4 +121,8 @@ class Todo < ActiveRecord::Base starred? end + def from_recurring_todo? + return self.recurring_todo_id != nil + end + end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index 8273e22a..8afb8e41 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -63,6 +63,9 @@ class User < ActiveRecord::Base has_many :todos, :order => 'todos.completed_at DESC, todos.created_at DESC', :dependent => :delete_all + has_many :recurring_todos, + :order => 'recurring_todos.completed_at DESC, recurring_todos.created_at DESC', + :dependent => :delete_all has_many :deferred_todos, :class_name => 'Todo', :conditions => [ 'state = ?', 'deferred' ], diff --git a/app/views/layouts/standard.html.erb b/app/views/layouts/standard.html.erb index 3f3e5b43..de66ec95 100644 --- a/app/views/layouts/standard.html.erb +++ b/app/views/layouts/standard.html.erb @@ -18,6 +18,7 @@ window.onload=function(){ Nifty("div#todo_new_action_container","normal"); Nifty("div#project_new_project_container","normal"); Nifty("div#context_new_container","normal"); + Nifty("div#recurring_new_container","normal"); if ($('flash').visible()) { new Effect.Fade("flash",{duration:5.0}); } } @@ -56,6 +57,7 @@ window.onload=function(){ <% if current_user.is_admin? -%>
  • <%= navigation_link("Admin", users_path, {:accesskey => "a", :title => "Add or delete users"} ) %>
  • <% end -%> +
  • <%= navigation_link(image_tag("recurring_menu16x16.png", :size => "16X16", :border => 0), {:controller => "recurring_todos", :action => "index"}, :title => "Manage recurring actions" ) %>
  • <%= navigation_link(image_tag("feed-icon.png", :size => "16X16", :border => 0), {:controller => "feedlist", :action => "index"}, :title => "See a list of available feeds" ) %>
  • <%= navigation_link(image_tag("menustar.gif", :size => "16X16", :border => 0), tag_path("starred"), :title => "See your starred actions" ) %>
  • <%= navigation_link(image_tag("stats.gif", :size => "16X16", :border => 0), {:controller => "stats", :action => "index"}, :title => "See your statistics" ) %>
  • diff --git a/app/views/recurring_todos/_edit_form.html.erb b/app/views/recurring_todos/_edit_form.html.erb new file mode 100644 index 00000000..aea4be4d --- /dev/null +++ b/app/views/recurring_todos/_edit_form.html.erb @@ -0,0 +1,147 @@ +
    +
    + <% form_remote_tag( + :url => recurring_todo_path(@recurring_todo), :method => :put, + :html=> { :id=>'recurring-todo-form-edit-action', :name=>'recurring_todo', :class => 'inline-form' }, + :before => "$('recurring_todo_edit_action_submit').startWaiting()", + :complete => "$('recurring_todo_edit_action_submit').stopWaiting();", + :condition => "!$('recurring_todo_edit_action_submit').isWaiting()") do + -%> +
    <%= error_messages_for("item", :object_name => 'action') %>
    + +
    +
    + <%= + text_field_tag( "recurring_todo[description]", @recurring_todo.description, "size" => 30, "tabindex" => 1) -%> + + <%= + text_area_tag( "recurring_todo[notes]", @recurring_todo.notes, {:cols => 29, :rows => 6, :tabindex => 2}) -%> + + + " /> + + + + + + + + + <%= + text_field_tag "tag_list", @recurring_todo.tag_list, :size => 30, :tabindex => 5 -%> +
    +
    +
    +
    +
    + <%= radio_button_tag('recurring_edit_todo[recurring_period]', 'daily', @recurring_todo.recurring_period == 'daily')%> Daily
    + <%= radio_button_tag('recurring_edit_todo[recurring_period]', 'weekly', @recurring_todo.recurring_period == 'weekly')%> Weekly
    + <%= radio_button_tag('recurring_edit_todo[recurring_period]', 'monthly', @recurring_todo.recurring_period == 'monthly')%> Monthly
    + <%= radio_button_tag('recurring_edit_todo[recurring_period]', 'yearly', @recurring_todo.recurring_period == 'yearly')%> Yearly
    + <% #behaviour is set in index because behaviours in partials are not generated -%> +
    +
    +
    + <%= + text_field_tag("recurring_todo[start_from]", format_date(@recurring_todo.start_from), "size" => 12, "class" => "Date", "onfocus" => "Calendar.setup", "tabindex" => 6, "autocomplete" => "off") %>
    +
    +
    + <%= radio_button_tag('recurring_todo[ends_on]', 'no_end_date', @recurring_todo.ends_on == 'no_end_date')%> No end date
    + <%= radio_button_tag('recurring_todo[ends_on]', 'ends_on_number_of_times', @recurring_todo.ends_on == 'ends_on_number_of_times')%> Ends after <%= text_field_tag("recurring_todo[number_of_occurences]", @recurring_todo.number_of_occurences, "size" => 3, "tabindex" => 7) %> times
    + <%= radio_button_tag('recurring_todo[ends_on]', 'ends_on_end_date', @recurring_todo.ends_on == 'ends_on_end_date')%> Ends on <%= + text_field_tag('recurring_todo[end_date]', format_date(@recurring_todo.end_date), "size" => 12, "class" => "Date", "onfocus" => "Calendar.setup", "tabindex" => 8, "autocomplete" => "off") %>
    +
    +
    +
    + <%= radio_button_tag('recurring_todo[daily_selector]', 'daily_every_x_day', !@recurring_todo.only_work_days)%> Every <%= + text_field_tag( 'recurring_todo[daily_every_x_days]', @recurring_todo.every_other1, {"size" => 3, "tabindex" => 9}) %> day(s)
    + <%= radio_button_tag('recurring_todo[daily_selector]', 'daily_every_work_day', @recurring_todo.only_work_days)%> Every work day
    +
    +
    +
    + Returns every <%= text_field_tag('recurring_todo[weekly_every_x_week]', @recurring_todo.every_other1, {"size" => 3, "tabindex" => 9}) %> week on
    + <%= check_box_tag('recurring_todo[weekly_return_monday]', 'm', @recurring_todo.on_monday ) %> Monday + <%= check_box_tag('recurring_todo[weekly_return_tuesday]', 't', @recurring_todo.on_tuesday) %> Tuesday + <%= check_box_tag('recurring_todo[weekly_return_wednesday]', 'w', @recurring_todo.on_wednesday) %> Wednesday + <%= check_box_tag('recurring_todo[weekly_return_thursday]', 't', @recurring_todo.on_thursday) %> Thursday
    + <%= check_box_tag('recurring_todo[weekly_return_friday]', 'f', @recurring_todo.on_friday) %> Friday + <%= check_box_tag('recurring_todo[weekly_return_saturday]', 's', @recurring_todo.on_saturday) %> Saturday + <%= check_box_tag('recurring_todo[weekly_return_sunday]', 's', @recurring_todo.on_sunday) %> Sunday
    +
    +
    +
    + <%= radio_button_tag('recurring_todo[monthly_selector]', 'monthly_every_x_day', @recurring_todo.recurrence_selector == 0)%> Day <%= + text_field_tag('recurring_todo[monthly_every_x_day]', Time.now.mday, {"size" => 3, "tabindex" => 9}) %> on every <%= + text_field_tag('recurring_todo[monthly_every_x_month]', 1, {"size" => 3, "tabindex" => 10}) %> month
    + <%= radio_button_tag('recurring_todo[monthly_selector]', 'monthly_every_xth_day', @recurring_todo.recurrence_selector == 1)%> The <%= + select_tag('recurring_todo[monthly_every_xth_day]', options_for_select(@xth_day), {}) %> <%= + select_tag('recurring_todo[monthly_day_of_week]' , options_for_select(@days_of_week, Time.now.wday), {}) %> of every <%= + text_field_tag('recurring_todo[monthly_every_x_month]', 1, {"size" => 3, "tabindex" => 11}) %> month
    +
    +
    +
    + <%= radio_button_tag('recurring_todo[yearly_selector]', 'yearly_every_x_day', @recurring_todo.recurrence_selector == 0)%> Every <%= + select_tag('recurring_todo[yearly_month_of_year]', options_for_select(@months_of_year, @recurring_todo.every_other2), {}) %> <%= + text_field_tag('recurring_todo[yearly_every_x_day]', @recurring_todo.every_other1, "size" => 3, "tabindex" => 9) %>
    + <%= radio_button_tag('recurring_todo[yearly_selector]', 'yearly_every_xth_day', @recurring_todo.recurrence_selector == 1)%> The <%= + select_tag('recurring_todo[yearly_every_xth_day]', options_for_select(@xth_day, @recurring_todo.every_other3), {}) %> <%= + select_tag('recurring_todo[yearly_day_of_week]', options_for_select(@days_of_week, @recurring_todo.every_count), {}) %> of <%= + select_tag('recurring_todo[yearly_month_of_year]', options_for_select(@months_of_year, @recurring_todo.every_other2), {}) %>
    +
    +
    +
    + <%= radio_button_tag('recurring_todo[recurring_target]', 'due_date', @recurring_todo.target == 'due_date')%> the date that the todo is due. + Show the todo <%= + text_field_tag( 'recurring_todo[recurring_show_days_before]', @recurring_todo.show_from_delta, {"size" => 3, "tabindex" => 12}) %> + days before the todo is due (0=show always)
    + <%= radio_button_tag('recurring_todo[recurring_target]', 'show_from_date', @recurring_todo.target == 'show_from_date')%> the date todo comes from tickler (no due date set)
    +
    +
    +
    +
    + + +
    +
    + <% end %> + <%= calendar_setup( "recurring_todo_start_from" ) %> + <%= calendar_setup( "recurring_todo_end_date" ) %> +
    diff --git a/app/views/recurring_todos/_recurring_todo.html.erb b/app/views/recurring_todos/_recurring_todo.html.erb new file mode 100644 index 00000000..3962b27b --- /dev/null +++ b/app/views/recurring_todos/_recurring_todo.html.erb @@ -0,0 +1,12 @@ +<% @recurring_todo = recurring_todo -%> +
    + <%= recurring_todo_remote_delete_icon %> <%= recurring_todo_remote_edit_icon -%> + + <%= recurring_todo_remote_star_icon %> <%= recurring_todo_remote_toggle_checkbox -%> +
    + <%= sanitize(recurring_todo.description) %> <%= recurring_todo_tag_list %> + + [<%=recurrence_target(recurring_todo)%> <%= recurring_todo.recurrence_pattern %> <%= recurrence_time_span(recurring_todo) %>] + +
    +
    diff --git a/app/views/recurring_todos/_recurring_todo_form.erb b/app/views/recurring_todos/_recurring_todo_form.erb new file mode 100644 index 00000000..f6c1fc4e --- /dev/null +++ b/app/views/recurring_todos/_recurring_todo_form.erb @@ -0,0 +1,145 @@ +
    + <% form_remote_tag( + :url => recurring_todos_path, :method => :post, + :html=> { :id=>'recurring-todo-form-new-action', :name=>'recurring_todo', :class => 'inline-form' }, + :before => "$('recurring_todo_new_action_submit').startWaiting()", + :complete => "$('recurring_todo_new_action_submit').stopWaiting();", + :condition => "!$('recurring_todo_new_action_submit').isWaiting()") do + -%> +
    <%= error_messages_for("item", :object_name => 'action') %>
    + +
    +
    + <%= + text_field_tag( "recurring_todo[description]", "", "size" => 30, "tabindex" => 1) -%> + + <%= + text_area_tag( "recurring_todo[notes]", nil, {:cols => 29, :rows => 6, :tabindex => 2}) -%> + + + + + + + + + + + + <%= + text_field_tag "tag_list", nil, :size => 30, :tabindex => 5 -%> +
    +
    +
    +
    + <%= radio_button_tag('recurring_todo[recurring_period]', 'daily', true)%> Daily
    + <%= radio_button_tag('recurring_todo[recurring_period]', 'weekly')%> Weekly
    + <%= radio_button_tag('recurring_todo[recurring_period]', 'monthly')%> Monthly
    + <%= radio_button_tag('recurring_todo[recurring_period]', 'yearly')%> Yearly
    + <% apply_behaviour "#recurring_period_id:click", + "TracksForm.hide_all_recurring(); $('recurring_'+TracksForm.get_period()).show();" %> +
    +
    + <%= + text_field(:recurring_todo, :start_from, "size" => 12, "class" => "Date", "onfocus" => "Calendar.setup", "tabindex" => 6, "autocomplete" => "off") %>
    +
    +
    + <%= radio_button_tag('recurring_todo[ends_on]', 'no_end_date', true)%> No end date
    + <%= radio_button_tag('recurring_todo[ends_on]', 'ends_on_number_of_times')%> Ends after <%= text_field( :recurring_todo, :number_of_occurences, "size" => 3, "tabindex" => 7) %> times
    + <%= radio_button_tag('recurring_todo[ends_on]', 'ends_on_end_date')%> Ends on <%= text_field(:recurring_todo, :end_date, "size" => 12, "class" => "Date", "onfocus" => "Calendar.setup", "tabindex" => 8, "autocomplete" => "off") %>
    +
    +
    +
    + <%= radio_button_tag('recurring_todo[daily_selector]', 'daily_every_x_day', true)%> Every <%= + text_field_tag( 'recurring_todo[daily_every_x_days]', "1", {"size" => 3, "tabindex" => 9}) %> day(s)
    + <%= radio_button_tag('recurring_todo[daily_selector]', 'daily_every_work_day')%> Every work day
    +
    + + + +
    +
    + <%= radio_button_tag('recurring_todo[recurring_target]', 'due_date', true)%> the date that the todo is due. + Show the todo <%= + text_field_tag( 'recurring_todo[recurring_show_days_before]', "0", {"size" => 3, "tabindex" => 12}) %> + days before the todo is due (0=show always)
    + <%= radio_button_tag('recurring_todo[recurring_target]', 'show_from_date', false)%> the date todo comes from tickler (no due date set)
    +
    +
    +
    +
    + + +
    +
    + <% end %> + <%= calendar_setup( "recurring_todo_start_from" ) %> + <%= calendar_setup( "recurring_todo_end_date" ) %> +
    diff --git a/app/views/recurring_todos/create.js.rjs b/app/views/recurring_todos/create.js.rjs new file mode 100644 index 00000000..9bf623f3 --- /dev/null +++ b/app/views/recurring_todos/create.js.rjs @@ -0,0 +1,20 @@ +page.show 'status' +page.replace_html 'status', "#{error_messages_for('recurring_todo')}" + +page.notify :notice, @message, 5.0 +if @recurring_saved + # reset form + page << "TracksForm.hide_all_recurring(); $('recurring_daily').show();" + page << "Form.reset('recurring-todo-form-new-action');" + page << "Form.focusFirstElement('recurring-todo-form-new-action');" + # hide overlayed edit form + page << "TracksForm.toggle_overlay();" + # insert new recurring todo + page.hide 'recurring-todos-empty-nd' + page.insert_html :bottom, + 'recurring_todos_container', + :partial => 'recurring_todos/recurring_todo' + page.visual_effect :highlight, dom_id(@recurring_todo), :duration => 3 + # update badge count + page['badge_count'].replace_html @count +end diff --git a/app/views/recurring_todos/destroy.js.rjs b/app/views/recurring_todos/destroy.js.rjs new file mode 100644 index 00000000..1c5f0307 --- /dev/null +++ b/app/views/recurring_todos/destroy.js.rjs @@ -0,0 +1,12 @@ +if @saved + if @remaining == 0 + page.show 'recurring-todos-empty-nd' + end + page.notify :notice, "The recurring action was deleted succesfully. " + + "The recurrence pattern is removed from " + + pluralize(@number_of_todos, "todo"), 5.0 + page[@recurring_todo].remove + page.visual_effect :fade, dom_id(@recurring_todo), :duration => 0.4 +else + page.notify :error, "There was an error deleting the recurring todo #{@recurring_todo.description}", 8.0 +end \ No newline at end of file diff --git a/app/views/recurring_todos/edit.js.rjs b/app/views/recurring_todos/edit.js.rjs new file mode 100644 index 00000000..9b4706b1 --- /dev/null +++ b/app/views/recurring_todos/edit.js.rjs @@ -0,0 +1,4 @@ +page << "TracksForm.toggle_overlay();" +page['new-recurring-todo'].hide +page['edit-recurring-todo'].replace_html :partial => 'recurring_todos/edit_form' +page['edit-recurring-todo'].show diff --git a/app/views/recurring_todos/index.html.erb b/app/views/recurring_todos/index.html.erb new file mode 100644 index 00000000..936773d8 --- /dev/null +++ b/app/views/recurring_todos/index.html.erb @@ -0,0 +1,42 @@ +
    +
    +

    Recurring todos

    +
    +
    +

    Currently there are no recurring todos

    +
    + <%= render :partial => "recurring_todo", :collection => @recurring_todos %> +
    +
    + +
    +

    Recurring todos that are completed

    +
    +
    +

    Currently there are no completed recurring todos

    +
    + <%= render :partial => "recurring_todo", :collection => @completed_recurring_todos %> +
    +
    +
    +
    + +
    + +
    +
    +
    + <%= render :partial => "recurring_todo_form" %> +
    + +
    <% + +# need to add behaviour for edit form here. Behaviour defined in partials are +# not generated for +apply_behaviour "#recurring_edit_period:click", + "TracksForm.hide_all_edit_recurring(); $('recurring_edit_'+TracksForm.get_edit_period()).show();" +-%> diff --git a/app/views/recurring_todos/new.html.erb b/app/views/recurring_todos/new.html.erb new file mode 100644 index 00000000..7eb32683 --- /dev/null +++ b/app/views/recurring_todos/new.html.erb @@ -0,0 +1 @@ +<%= render :partial => "recurring_todo_form" %> \ No newline at end of file diff --git a/app/views/recurring_todos/show.html.erb b/app/views/recurring_todos/show.html.erb new file mode 100644 index 00000000..586085f3 --- /dev/null +++ b/app/views/recurring_todos/show.html.erb @@ -0,0 +1,2 @@ +

    RecurringTodo#show

    +

    Find me in app/views/recurring_todo/show.html.erb

    diff --git a/app/views/recurring_todos/toggle_check.js.rjs b/app/views/recurring_todos/toggle_check.js.rjs new file mode 100644 index 00000000..aa21b8c9 --- /dev/null +++ b/app/views/recurring_todos/toggle_check.js.rjs @@ -0,0 +1,30 @@ +if @saved + page[@recurring_todo].remove + page['badge_count'].replace_html @count + + if @recurring_todo.completed? + # show completed recurring todo + page.insert_html :top, "completed_recurring_todos_container", :partial => 'recurring_todos/recurring_todo' + page.visual_effect :highlight, dom_id(@recurring_todo), :duration => 3 + + # set empty messages + page.show 'recurring-todos-empty-nd' if @remaining == 0 + page.hide 'completed-empty-nd' + else + # recurring_todo is activated + + # show completed recurring todo + page.insert_html :top, "recurring_todos_container", :partial => 'recurring_todos/recurring_todo' + page.visual_effect :highlight, dom_id(@recurring_todo), :duration => 3 + + # inform user if a new todo has been created because of the activation + page.notify :notice, "A new todo was added which belongs to this recurring todo", 3.0 unless @new_recurring_todo.nil? + + # set empty messages + page.show 'completed-empty-nd' if @remaining == 0 + page.hide 'recurring-todos-empty-nd' + end + +else + page.notify :error, "There was an error completing / activating the recurring todo #{@recurring_todo.description}", 8.0 +end \ No newline at end of file diff --git a/app/views/recurring_todos/toggle_star.js.rjs b/app/views/recurring_todos/toggle_star.js.rjs new file mode 100644 index 00000000..f9846bdf --- /dev/null +++ b/app/views/recurring_todos/toggle_star.js.rjs @@ -0,0 +1,3 @@ +if @saved + page[@recurring_todo].down('a.star_item').down('img').toggleClassName('starred_todo').toggleClassName('unstarred_todo') +end \ No newline at end of file diff --git a/app/views/recurring_todos/update.js.rjs b/app/views/recurring_todos/update.js.rjs new file mode 100644 index 00000000..4486c800 --- /dev/null +++ b/app/views/recurring_todos/update.js.rjs @@ -0,0 +1,22 @@ +if @saved + # hide overlayed edit form + page << "TracksForm.toggle_overlay();" + + # show update message + status_message = 'Recurring action saved' + status_message = 'Added new project / ' + status_message if @new_project_created + status_message = 'Added new context / ' + status_message if @new_context_created + page.notify :notice, status_message, 5.0 + + # update auto completer arrays for context and project + page << "contextAutoCompleter.options.array = #{context_names_for_autocomplete}; contextAutoCompleter.changed = true" if @new_context_created + page << "projectAutoCompleter.options.array = #{project_names_for_autocomplete}; projectAutoCompleter.changed = true" if @new_project_created + + # replace old recurring todo with updated todo + page.replace dom_id(@recurring_todo), :partial => 'recurring_todos/recurring_todo' + page.visual_effect :highlight, dom_id(@recurring_todo), :duration => 3 + +else + page.show 'error_status' + page.replace_html 'error_status', "#{error_messages_for('todo')}" +end \ No newline at end of file diff --git a/app/views/todos/_todo.html.erb b/app/views/todos/_todo.html.erb index d4df682f..5ca9522d 100644 --- a/app/views/todos/_todo.html.erb +++ b/app/views/todos/_todo.html.erb @@ -13,6 +13,7 @@
    <%= date_span -%> <%= h sanitize(todo.description) %> + <%= link_to(image_tag("recurring16x16.png"), {:controller => "recurring_todos", :action => "index"}, :class => "recurring_icon") if @todo.from_recurring_todo? %> <%= tag_list %> <%= deferred_due_date %> <%= project_and_context_links( parent_container_type, :suppress_context => suppress_context, :suppress_project => suppress_project ) %> diff --git a/app/views/todos/create.js.rjs b/app/views/todos/create.js.rjs index 8865e2b3..eb796a77 100644 --- a/app/views/todos/create.js.rjs +++ b/app/views/todos/create.js.rjs @@ -15,7 +15,7 @@ if @saved page.insert_html :top, 'display_box', :partial => 'contexts/context', :locals => { :context => @todo.context, :collapsible => true } else page.call "todoItems.ensureVisibleWithEffectAppear", "c#{@todo.context_id}" if source_view_is_one_of(:todo, :deferred) - page.insert_html :bottom, item_container_id + 'items', :partial => 'todos/todo', :locals => { :parent_container_type => parent_container_type, :source_view => @source_view } + page.insert_html :bottom, item_container_id(@todo) + 'items', :partial => 'todos/todo', :locals => { :parent_container_type => parent_container_type, :source_view => @source_view } page.visual_effect :highlight, dom_id(@todo), :duration => 3 page[empty_container_msg_div_id].hide unless empty_container_msg_div_id.nil? end diff --git a/app/views/todos/destroy.js.rjs b/app/views/todos/destroy.js.rjs index efea565f..7f37f87d 100644 --- a/app/views/todos/destroy.js.rjs +++ b/app/views/todos/destroy.js.rjs @@ -1,9 +1,23 @@ if @saved page[@todo].remove page['badge_count'].replace_html @down_count - page.visual_effect :fade, item_container_id, :duration => 0.4 if source_view_is_one_of(:todo, :deferred) && @remaining_in_context == 0 + + # remove context if empty + page.visual_effect :fade, item_container_id(@todo), :duration => 0.4 if source_view_is_one_of(:todo, :deferred) && @remaining_in_context == 0 + + # show message if there are no actions page[empty_container_msg_div_id].show if !empty_container_msg_div_id.nil? && @down_count == 0 page['tickler-empty-nd'].show if source_view_is(:deferred) && @down_count == 0 + + # show new todo if the completed todo was recurring + unless @new_recurring_todo.nil? + page.call "todoItems.ensureVisibleWithEffectAppear", item_container_id(@new_recurring_todo) + page.insert_html :bottom, item_container_id(@new_recurring_todo), :partial => 'todos/todo', :locals => { :todo => @new_recurring_todo, :parent_container_type => parent_container_type } + page.visual_effect :highlight, dom_id(@new_recurring_todo, 'line'), {'startcolor' => "'#99ff99'"} + page.notify :notice, "Action was deleted. Because this action is recurring, a new action was added", 6.0 + else + page.notify :notice, "There is no next action after the recurring action you just deleted. The recurrence is completed", 6.0 unless @recurring_todo.nil? + end else page.notify :error, "There was an error deleting the item #{@todo.description}", 8.0 end \ No newline at end of file diff --git a/app/views/todos/toggle_check.js.rjs b/app/views/todos/toggle_check.js.rjs index 3b3632aa..856d7d0d 100644 --- a/app/views/todos/toggle_check.js.rjs +++ b/app/views/todos/toggle_check.js.rjs @@ -1,7 +1,8 @@ if @saved page[@todo].remove if @todo.completed? - # Don't try to insert contents into a non-existent container! + # completed todos move from their context to the completed container + unless @prefs.hide_completed_actions? page.insert_html :top, "completed", :partial => 'todos/todo', :locals => { :parent_container_type => "completed" } page.visual_effect :highlight, dom_id(@todo, 'line'), {'startcolor' => "'#99ff99'"} @@ -9,16 +10,30 @@ if @saved page.show 'tickler-empty-nd' if source_view_is(:project) && @deferred_count == 0 page.hide 'empty-d' # If we've checked something as done, completed items can't be empty end + + # remove container if empty if @remaining_in_context == 0 && source_view_is(:todo) - page.visual_effect :fade, item_container_id, :duration => 0.4 + page.visual_effect :fade, item_container_id(@todo), :duration => 0.4 end + + # show new todo if the completed todo was recurring + unless @new_recurring_todo.nil? + page.call "todoItems.ensureVisibleWithEffectAppear", item_container_id(@new_recurring_todo) + page.insert_html :bottom, item_container_id(@new_recurring_todo), :partial => 'todos/todo', :locals => { :todo => @new_recurring_todo, :parent_container_type => parent_container_type } + page.visual_effect :highlight, dom_id(@new_recurring_todo, 'line'), {'startcolor' => "'#99ff99'"} + else + page.notify :notice, "There is no next action after the recurring action you just finished. The recurrence is completed", 6.0 unless @recurring_todo.nil? + end + else - page.call "todoItems.ensureVisibleWithEffectAppear", item_container_id - page.insert_html :bottom, item_container_id, :partial => 'todos/todo', :locals => { :parent_container_type => parent_container_type } + # todo is activated from completed container + page.call "todoItems.ensureVisibleWithEffectAppear", item_container_id(@todo) + page.insert_html :bottom, item_container_id(@todo), :partial => 'todos/todo', :locals => { :parent_container_type => parent_container_type } page.visual_effect :highlight, dom_id(@todo, 'line'), {'startcolor' => "'#99ff99'"} page.show "empty-d" if @completed_count == 0 page[empty_container_msg_div_id].hide unless empty_container_msg_div_id.nil? # If we've checked something as undone, incomplete items can't be empty end + page.hide "status" page.replace_html "badge_count", @down_count if @todo.completed? && !@todo.project_id.nil? && @prefs.show_project_on_todo_done && !source_view_is(:project) diff --git a/app/views/todos/update.js.rjs b/app/views/todos/update.js.rjs index fa0bbf0d..76a7632e 100644 --- a/app/views/todos/update.js.rjs +++ b/app/views/todos/update.js.rjs @@ -6,7 +6,7 @@ if @saved status_message = 'Added new context / ' + status_message if @new_context_created page.notify :notice, status_message, 5.0 - #update auto completer arrays for context and project + # #update auto completer arrays for context and project page << "contextAutoCompleter.options.array = #{context_names_for_autocomplete}; contextAutoCompleter.changed = true" if @new_context_created page << "projectAutoCompleter.options.array = #{project_names_for_autocomplete}; projectAutoCompleter.changed = true" if @new_project_created if source_view_is_one_of(:todo, :context) @@ -88,9 +88,9 @@ if @saved page.replace dom_id(@todo), :partial => 'todos/todo', :locals => { :parent_container_type => parent_container_type } page.visual_effect :highlight, dom_id(@todo), :duration => 3 end - elsif source_view_is :stats - page.replace dom_id(@todo), :partial => 'todos/todo', :locals => { :parent_container_type => parent_container_type } - page.visual_effect :highlight, dom_id(@todo), :duration => 3 + elsif source_view_is :stats + page.replace dom_id(@todo), :partial => 'todos/todo', :locals => { :parent_container_type => parent_container_type } + page.visual_effect :highlight, dom_id(@todo), :duration => 3 else logger.error "unexpected source_view '#{params[:_source_view]}'" end diff --git a/config/routes.rb b/config/routes.rb index 4453c668..a4eb29e3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -58,6 +58,10 @@ ActionController::Routing::Routes.draw do |map| map.preferences 'preferences', :controller => 'preferences', :action => 'index' map.integrations 'integrations', :controller => 'integrations', :action => 'index' + map.resources :recurring_todos, + :member => {:toggle_check => :put, :toggle_star => :put} + map.recurring_todos 'recurring_todos', :controller => 'recurring_todos', :action => 'index' + # Install the default route as the lowest priority. map.connect ':controller/:action/:id' diff --git a/db/migrate/039_create_recurring_todos.rb b/db/migrate/039_create_recurring_todos.rb new file mode 100644 index 00000000..ca02b163 --- /dev/null +++ b/db/migrate/039_create_recurring_todos.rb @@ -0,0 +1,52 @@ +class CreateRecurringTodos < ActiveRecord::Migration + def self.up + create_table :recurring_todos do |t| + # todo data + t.column :user_id, :integer, :default => 1 + t.column :context_id, :integer, :null => false + t.column :project_id, :integer + t.column :description, :string, :null => false + t.column :notes, :text + t.column :state, :string, :limit => 20, :default => "active", :null => false + # running time + t.column :start_from, :date + t.column :ends_on, :string # no_end_date, ends_on_number_of_times, ends_on_end_date + t.column :end_date, :date # end_date should be null when + # number_of_occurrences is not null + t.column :number_of_occurences, :integer + t.column :occurences_count, :integer, :default => 0 # current count + # target + t.column :target, :string # 'due_date' or 'show_from' + t.column :show_from_delta, :integer # number of days before due date + # recurring parameters + t.column :recurring_period, :string # daily, monthly, yearly + t.column :recurrence_selector, :integer # which recurrence is selected + t.column :every_other1, :integer # every 1 day, every 2nd week, + # every day 12 of the month + t.column :every_other2, :integer # for month: every 12th of + # every 2 (other) month and + # year: every 12th of 3 (march) + t.column :every_other3, :integer # for months and years + t.column :every_day, :string # for weekly: 'smtwtfs' for + # every week on all days or + # ' m w f ' for every week on + # every other day + t.column :only_work_days, :boolean, :default => false # for daily + t.column :every_count, :integer # monthly and yearly to describe + # the second monday of a month + t.column :weekday, :integer # monthly and yearly to describe + # day of week for every second + # saturday of the month + t.column :completed_at, :datetime + t.timestamps + end + + add_column :todos, :recurring_todo_id, :integer + + end + + def self.down + remove_column :todos, :recurring_todo_id + drop_table :recurring_todos + end +end diff --git a/public/images/add.png b/public/images/add.png new file mode 100644 index 0000000000000000000000000000000000000000..297ef4500381c8579d0cd86ba880eb0837a6af0d GIT binary patch literal 596 zcmV-a0;~OrP)OugAF8Zv-@?+OXe-qs-qr``MG^I>42VMaqZ=)1Z9c{e z7j3uO-8EU7n>`RnAT#F&nK?;lW~frGQujFk04-X$zR`-3G+icX0L-cT{O;((%8db} zS=M~Hw@-jHGweKnx?H4Bxko^v(``evc$?BQPuh!aXGTiIpaH`6ew@2?Uh1E!aBu!Rc?Z3X@4H)dt zDFHLU(ZLC!s&L}4^K93jv&j~IC9AK@{k<2v;W*>4b|>4~+zvg@gQtpvHwWMQ@rh2eOwgbz4G@Yye{C0Y3zVBNQ1Qy4! z1wml7TFs&;N~Z!iJUl!Da5fI0wYCaz4yp iK1rPan#}>K2KWtUKc8m0j#Od*0000!_@Ap3JAam4yqTWp@ zm6u6und0pKTUJGvUQlgm_>rt(_0xXE|F%a8J7gXzEV<-ZP_`jBwku5BF;-^Rs{*~a zwHLh?dShx?%I}V=6&2XOwVlX_)275^zAfK$LqYS3>5e zOG(A6Us>#!7yaVJ)0Z>PRem=R`{ZDq5hmMy@WD4j&!GDTb004jZkXMB_G@JVzXbQ0 zUGuyrEveI4GL<)Qa; z-e?E=t9|y9(@sW;o1T|t;!QBoEV#D&-COY@DUg&R)Iq|nqeEQuW#jv7X;i*}F7`{Kq{yc+q#ad7hd%F6$taD0e0swn; BsfPdn literal 0 HcmV?d00001 diff --git a/public/images/recurring24x24.png b/public/images/recurring24x24.png new file mode 100644 index 0000000000000000000000000000000000000000..8a604c84bd4aeb2534d20612d0bf48f94ccb867e GIT binary patch literal 598 zcmV-c0;&CpP)lii z=|UX@hYanK-R^?maD96n#h7FYB}40vmJ&3$Iwz$d;&7hlpU|zVAQWm{8Wri`WFU}9 z`a0ab;hN?ymx3UE;pL8(=ly=4_j#Z11OFhbX#F*mm1FCyBG;~}q8$4SD9?(^P1#!j zZUEO4&MX4d>Q#Wx@85k`27Uny27^+uSo|p>-%YSgk}JpF0=8#m774J|+mCN=zj^)k zV?Lk%Dk5Jd1s0TJcYx+@dxzjGn7eF|Sj%($BM~{6EYYGNf8OrQB&*e{SS9O1Y^`2p zuebjSXaJW4&Vg~}p6fS~$k*0ayN29z{YDT3Y_yu)X|cJ4|L}oCdeaUm$L<>PC}v4H z_S9tJZTfqjddJ99&&CCE>6F|`AWucs8(7M*Pqo(dw9?HINX$HP?Uj%y5Cj2)bLZ5U zs^9NJI3CPeioV4pp2WIuw3=Pz*ki{6Jbt>_P3jf^t8{t(jh#Fk4(~jC^k4<(#7VOv zX1W)i>;EwCG#1cWmw}bF_0`+Jrr9@RV`Anc;pLO9XWuH7%CU$XUMP`>oM^4jJ=cfO z>Okn)-S!TU?(WfOM4?as_yn9}y0+GOS!;byIkr}gtxcQGgVZ(^kt5*PbN%~sKZ@bV kc$1sJ-AFXE$^H-g0g+0ro?GxyFaQ7m07*qoM6N<$g3I+Bv;Y7A literal 0 HcmV?d00001 diff --git a/public/images/recurring_menu16x16.png b/public/images/recurring_menu16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..6e3506ae806bd113cb4ebef50046c457578a1032 GIT binary patch literal 475 zcmV<10VMv3P)b8m0cc4? zK~#9!t&=fGLO~eEf0j^lTi_ss3b{mqL}CUGEe{EDFc6ybO-|9$A}ua8>7=&#aI&Ub z-e8dE+sq}R@x~T)Zh9@xm*+Ec=no%%?!Nne_uX;l<45;E2p9r~z#DJ|^nqib>-aU$ zW=?6q{`$1+Roqz*z`Elf!{LyP_bWC-m+aXOOTF9eUI4cM{wXOC2vB)CA(>24d4YI5 zPNnblNF<`P?sMF>ZJW_(#9y*3i*!2e*7|3YlgR|bct(f;wORoHU9T&EQb|+B3P6Zg zgcx9&<%PSOW*N(}eg_yv2TeObO6e&Mhqq}o1kvc0GQT#zuvk1N7!0~G-8mN9rQNnX zU=N^LeMCyh>FEtpN@}$NQc4nuorT9zj*!cxFpLgD3;^5?0GiFtLV(hNrtK?9DWw~R z(IJyLS`Nr&@08^99(27cdx@v7EBj?0A#ORgu$PDJfkG2YTq8=H^K)}k^GX;(^7C_&5{q<; zGpkZl6f%?Z^SEr002h}=vZjKOfq}7tS7LT)l7f0xW^Qg`vbsWWNor@#vd1gt5LP#uJ$|i2UcIOf1uctr#$!5mbjKBvpHr*wa;>xEPqiqW7D*bQ+2XMDOWd!l)XII z|NhMHy+8MU|GW45vxD^$ULR0?V5A^^Wako{rBfPWL>ezUJjicf&QST|-Aa}x4_C8u zD;Dra*uHvlrKQ~o%-4h3IGi<*W_u`Zs)ktYv=A-0jA%4Q88OmspuM z9zT$t(7-QyGsEP>^Up7K`K`XHB-MK{ zp-zEbx6Y$U9-5viLM+XWk3R0umuhQVq|xG}_`buq;n7|nLkS+XFIBd`{{Fj@x_p&Z z-{T2hOBvoVg+5rU&+Hm_!GzcMa^KQt;eV#Ad)+7T=DqpKkXa(#k_=_#@_jeOx+OzG zW6uEHwJkeD%Xih)-}~gxUpjwwLCC6!K6wf)Gd4xW+j}nzNXyPnu4H4Vt}d@p-u_l< zY0%m7dY$E)B`N~Nxl&sMBEt9-S&k;LDYDF-EzT(5^kCP!BA!c`lY?IR%P|~!_~FE{ zZP~%QS|uw2tCU(K)-L`TIjb@C>9H?yTc%A_o@;jVrs4lc*WkH2OPAb!J1<7vKkVYh z@=dRIU3vXgSbDmDTXXWwQidGnTWe(2Foic1I}~&>)HA(roPHqOp8eE^`gdoq9cbdK iW4zy(fB5<2>G!=p84LCN6W#_)#tfdWelF{r5}E+prCOi> literal 0 HcmV?d00001 diff --git a/public/images/trans70.png b/public/images/trans70.png new file mode 100644 index 0000000000000000000000000000000000000000..7970848044eb8202d9cca53ac3c8cefb8c902ffe GIT binary patch literal 328 zcmeAS@N?(olHy`uVBq!ia0vp^DImQL70(Y)*K0-AbW|YuPgfvb~b)X!;fETLFyz+Tq8=H^K)}k z^GX<;i&7IyQd1PlGfOfQ+&z5*!W;R-fr=J;x;TbZ+IPR zwodXXS+QMyS2}wnxomtUhh{cP7xe44$rjF6*2UngEt> BQi=co literal 0 HcmV?d00001 diff --git a/public/javascripts/application.js b/public/javascripts/application.js index 66ff9a4b..b22fd832 100644 --- a/public/javascripts/application.js +++ b/public/javascripts/application.js @@ -1,39 +1,90 @@ var Login = { - showOpenid: function() { - if ($('database_auth_form')) $('database_auth_form').hide(); - if ($('openid_auth_form')) $('openid_auth_form').show(); - if ($('alternate_auth_openid')) $('alternate_auth_openid').hide(); - if ($('alternate_auth_database')) $('alternate_auth_database').show(); - if ($('openid_url')) $('openid_url').focus(); - if ($('openid_url')) $('openid_url').select(); + showOpenid: function() { + if ($('database_auth_form')) $('database_auth_form').hide(); + if ($('openid_auth_form')) $('openid_auth_form').show(); + if ($('alternate_auth_openid')) $('alternate_auth_openid').hide(); + if ($('alternate_auth_database')) $('alternate_auth_database').show(); + if ($('openid_url')) $('openid_url').focus(); + if ($('openid_url')) $('openid_url').select(); new CookieManager().setCookie('preferred_auth', 'openid'); - }, + }, - showDatabase: function(container) { - if ($('openid_auth_form')) $('openid_auth_form').hide(); - if ($('database_auth_form')) $('database_auth_form').show(); - if ($('alternate_auth_database')) $('alternate_auth_database').hide(); - if ($('alternate_auth_openid')) $('alternate_auth_openid').show(); - if ($('user_login')) $('user_login').focus(); - if ($('user_login')) $('user_login').select(); + showDatabase: function(container) { + if ($('openid_auth_form')) $('openid_auth_form').hide(); + if ($('database_auth_form')) $('database_auth_form').show(); + if ($('alternate_auth_database')) $('alternate_auth_database').hide(); + if ($('alternate_auth_openid')) $('alternate_auth_openid').show(); + if ($('user_login')) $('user_login').focus(); + if ($('user_login')) $('user_login').select(); new CookieManager().setCookie('preferred_auth', 'database'); - } + } } var TracksForm = { - toggle: function(toggleDivId, formContainerId, formId, hideLinkText, hideLinkTitle, showLinkText, showLinkTitle) { - $(formContainerId).toggle(); - toggleDiv = $(toggleDivId); - toggleLink = toggleDiv.down('a'); - if (toggleDiv.hasClassName('hide_form')) { - toggleLink.update(showLinkText).setAttribute('title', showLinkTitle); + toggle: function(toggleDivId, formContainerId, formId, hideLinkText, hideLinkTitle, showLinkText, showLinkTitle) { + $(formContainerId).toggle(); + toggleDiv = $(toggleDivId); + toggleLink = toggleDiv.down('a'); + if (toggleDiv.hasClassName('hide_form')) { + toggleLink.update(showLinkText).setAttribute('title', showLinkTitle); + } + else { + toggleLink.update(hideLinkText).setAttribute('title', hideLinkTitle); + Form.focusFirstElement(formId); + } + toggleDiv.toggleClassName('hide_form'); + }, + get_period: function() { + if ($('recurring_todo_recurring_period_daily').checked) { + return 'daily'; + } + else if ($('recurring_todo_recurring_period_weekly').checked) { + return 'weekly'; + } + else if ($('recurring_todo_recurring_period_monthly').checked) { + return 'monthly'; + } + else if ($('recurring_todo_recurring_period_yearly').checked) { + return 'yearly'; + } + else { + return 'no period' + } + }, + get_edit_period: function() { + if ($('recurring_edit_todo_recurring_period_daily').checked) { + return 'daily'; + } + else if ($('recurring_edit_todo_recurring_period_weekly').checked) { + return 'weekly'; + } + else if ($('recurring_edit_todo_recurring_period_monthly').checked) { + return 'monthly'; + } + else if ($('recurring_edit_todo_recurring_period_yearly').checked) { + return 'yearly'; + } + else { + return 'no period' + } + }, + hide_all_recurring: function () { + $('recurring_daily').hide(); + $('recurring_weekly').hide(); + $('recurring_monthly').hide(); + $('recurring_yearly').hide(); + }, + hide_all_edit_recurring: function () { + $('recurring_edit_daily').hide(); + $('recurring_edit_weekly').hide(); + $('recurring_edit_monthly').hide(); + $('recurring_edit_yearly').hide(); + }, + toggle_overlay: function () { + el = document.getElementById("overlay"); + el.style.visibility = (el.style.visibility == "visible") ? "hidden" : "visible"; } - else { - toggleLink.update(hideLinkText).setAttribute('title', hideLinkTitle); - Form.focusFirstElement(formId); - } - toggleDiv.toggleClassName('hide_form'); - } } + // uncomment the next four lines for easier debugging with FireBug // Ajax.Responders.register({ // onException: function(source, exception) { @@ -43,10 +94,10 @@ var TracksForm = { /* fade flashes automatically */ Event.observe(window, 'load', function() { - $A(document.getElementsByClassName('alert')).each(function(o) { - o.opacity = 100.0 - Effect.Fade(o, {duration: 8.0}) - }); + $A(document.getElementsByClassName('alert')).each(function(o) { + o.opacity = 100.0 + Effect.Fade(o, {duration: 8.0}) + }); }); /** @@ -55,12 +106,12 @@ Event.observe(window, 'load', function() { */ CookieManager = Class.create(); CookieManager.prototype = -{ + { BROWSER_IS_IE: (document.all - && window.ActiveXObject - && navigator.userAgent.toLowerCase().indexOf("msie") > -1 - && navigator.userAgent.toLowerCase().indexOf("opera") == -1), + && window.ActiveXObject + && navigator.userAgent.toLowerCase().indexOf("msie") > -1 + && navigator.userAgent.toLowerCase().indexOf("opera") == -1), /** * I hate navigator string based browser detection too, but when Opera alone diff --git a/public/stylesheets/standard.css b/public/stylesheets/standard.css index 38ad2c69..9c326508 100644 --- a/public/stylesheets/standard.css +++ b/public/stylesheets/standard.css @@ -3,75 +3,75 @@ /* @override http://0.0.0.0:3000/stylesheets/standard.css?1180885851 */ body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,form,fieldset,input,textarea,p,blockquote,th,td { - margin:0; - padding:0; + margin:0; + padding:0; } table { - border-collapse:collapse; - border-spacing:0; + border-collapse:collapse; + border-spacing:0; } fieldset,img { - border:0; + border:0; } address,caption,cite,code,dfn,em,strong,th,var { - font-style:normal; - font-weight:normal; + font-style:normal; + font-weight:normal; } ol,ul { - list-style:none; + list-style:none; } caption,th { - text-align:left; + text-align:left; } h1,h2,h3,h4,h5,h6 { - font-size:100%; - font-weight:normal; + font-size:100%; + font-weight:normal; } q:before,q:after { - content:''; + content:''; } abbr,acronym { border:0; } body { - font-family: "Lucida Grande", Verdana, Geneva, Arial, sans-serif; - font-size: 80%; - padding: 0px 10px; - margin: 0px; - background: #eee; - } + font-family: "Lucida Grande", Verdana, Geneva, Arial, sans-serif; + font-size: 80%; + padding: 0px 10px; + margin: 0px; + background: #eee; +} p { - padding: 2px; - font-size: 92%; - line-height: 140%; - } + padding: 2px; + font-size: 92%; + line-height: 140%; +} a, a:link, a:active, a:visited { - color: #cc3334; - text-decoration: none; - padding-left: 1px; - padding-right: 1px; - } - + color: #cc3334; + text-decoration: none; + padding-left: 1px; + padding-right: 1px; +} + a:hover { - color: #fff; - background-color: #cc3334; - } + color: #fff; + background-color: #cc3334; +} h1 { - font-size: 304%; - font-weight: bold; + font-size: 304%; + font-weight: bold; } h2 { - font-size: 148%; - font-weight: bold; + font-size: 148%; + font-weight: bold; } h3 { - font-size: 129%; - font-weight: bold; + font-size: 129%; + font-weight: bold; } /* Rules for the icon links */ @@ -105,287 +105,368 @@ a.show_notes:hover, a.link_to_notes:hover {background-image: url(../images/notes /* Structural divs */ #content { - margin-top: 90px; - } - + margin-top: 90px; +} + #display_box { - float: left; - width: 55%; - margin: 0px 10px 50px 15px; - } + float: left; + width: 55%; + margin: 0px 10px 50px 15px; +} #single_box { - width: 60%; - margin: 80px auto; - } + width: 60%; + margin: 80px auto; +} #full_width_display { - float: left; - width: 95%; - margin: 0px 15px 90px 15px; - } + float: left; + width: 95%; + margin: 0px 15px 90px 15px; +} #display_box_projects { - float: left; - width: 95%; - margin: 0px 15px 90px 15px; - } + float: left; + width: 95%; + margin: 0px 15px 90px 15px; +} + +#recurring_timespan, #recurring_target { + border: none; + clear: both; +} + +#recurring_target { + border-top-style: dotted; + padding: 15px 0px 0px 0px; +} + +#recurring_daily, #recurring_weekly, #recurring_monthly, #recurring_yearly, +#recurring_edit_daily, #recurring_edit_weekly, #recurring_edit_monthly, #recurring_edit_yearly { + border: none; + border-top-style: dotted; + clear: both; + padding: 15px 0px 15px 0px; +} + +#recurring_period_id, #recurring_edit_period_id { + border: none; + float: left; + margin: 0px 50px 0px 0px; + padding: 0px 25px 15px 50px; + border-right-style: none; +} + +#recurring_todo { + width: 270px; +} + +#recurring_todo_form_container { + border-right-style: dotted; + padding: 0px 50px 15px 0px; + float: left; +} + +#recurring_todo input, #recurring_todo textarea { + width: 100%; +} + +#overlay { + visibility: hidden; + position: absolute; + left: 0px; + top: 0px; + width: 100%; + height: 100%; + z-index: 102; + text-align: center; + background-image:url("../images/trans70.png"); +} + +#overlay #new-recurring-todo, #overlay #edit-recurring-todo { + width:750px; + background-color: #fff; + border:1px solid #000; + padding: 15px; + margin: 70px auto; +} + +.recurring_container { + padding: 0px 5px 0px 5px; + border: 1px solid #999; + margin: 0px 0px 0px 0px; + background: #fff; + text-align: left; +} + +.recurring_submit_box { + height: 25px; + padding: 5px 0; + text-align: center; + clear: both; + border: none; +} /* Navigation links at the top */ #navcontainer { - position: fixed; - top: 48px; - left: 0px; - } - + position: fixed; + top: 48px; + left: 0px; +} + #navlist { - margin: 0; - padding: 0 0 20px 5px; -/* border-bottom: 1px solid #000;*/ - } + margin: 0; + padding: 0 0 20px 5px; + /* border-bottom: 1px solid #000;*/ +} #navlist ul, #navlist li { - margin: 0; - padding: 0; - display: inline; - list-style-type: none; - } + margin: 0; + padding: 0; + display: inline; + list-style-type: none; +} #navlist a:link, #navlist a:visited { - float: left; - line-height: 14px; - font-weight: bold; - margin: 0 10px 4px 10px; - text-decoration: none; - color: #eee; - } + float: left; + line-height: 14px; + font-weight: bold; + margin: 0 10px 4px 10px; + text-decoration: none; + color: #eee; +} #navlist a:link#current, #navlist a:visited#current, #navlist a:hover { - border-bottom: 4px solid #CCC; - padding-bottom: 2px; - background: transparent; - color: #CCC; - } + border-bottom: 4px solid #CCC; + padding-bottom: 2px; + background: transparent; + color: #CCC; +} #navlist a:hover { color: #CCC; } #topbar { - position: fixed; - top: 0px; - left: 0px; - height: 68px; - margin-bottom: 20px; - clear: both; - background-color: #000; - filter: alpha(opacity=75); - -moz-opacity: .75; - opacity: .75; - color: #eee; - width: 100%; - z-index:1100; - } + position: fixed; + top: 0px; + left: 0px; + height: 68px; + margin-bottom: 20px; + clear: both; + background-color: #000; + filter: alpha(opacity=75); + -moz-opacity: .75; + opacity: .75; + color: #eee; + width: 100%; + z-index:101; +} body.stats #topbar { filter: alpha(opacity=100); -moz-opacity: 1; opacity: 1; } #date { - float: left; - width: 45%; - padding-left: 15px; - margin-top: 15px; - margin-bottom: 5px; - white-space: nowrap; /* added 2006-05-17 for safari display, timfm */ - } + float: left; + width: 45%; + padding-left: 15px; + margin-top: 15px; + margin-bottom: 5px; + white-space: nowrap; /* added 2006-05-17 for safari display, timfm */ +} #date h1 { - font-size: 152%; - } - + font-size: 152%; +} + #minilinks { - text-align: right; - position: fixed; - right: 15px; - top: 10px; - font-size: 0.9em; - } - + text-align: right; + position: fixed; + right: 15px; + top: 10px; + font-size: 0.9em; +} + .container { - padding: 0px 5px 0px 5px; - border: 1px solid #999; - margin: 0px 0px 15px 0px; - background: #fff; - } + padding: 0px 5px 0px 5px; + border: 1px solid #999; + margin: 0px 0px 15px 0px; + background: #fff; +} .completed { background: #eee; } - + .container h2 { - background: #ccc; - padding: 5px; - margin-top: 0px; - margin-left: -5px; - margin-right: -5px; - margin-bottom: 0px; - color: #666; - position:left; /* changed from relative to left in order to show 'add note' */ - /* text-shadow: rgba(0,0,0,.4) 0px 2px 5px; */ - } + background: #ccc; + padding: 5px; + margin-top: 0px; + margin-left: -5px; + margin-right: -5px; + margin-bottom: 0px; + color: #666; + position:left; /* changed from relative to left in order to show 'add note' */ + /* text-shadow: rgba(0,0,0,.4) 0px 2px 5px; */ +} .container_toggle img { - height:20px; - width:20px; - border:0px; - } + height:20px; + width:20px; + border:0px; +} h2 a, h2 a:link, h2 a:active, h2 a:visited { - color: #666; - text-decoration: none; - } - + color: #666; + text-decoration: none; +} + h2 a:hover { - color: #cc3334; - background-color: transparent; - text-decoration: none; - } - + color: #cc3334; + background-color: transparent; + text-decoration: none; +} + div#input_box { - margin: 0px 15px 0px 58%; - padding: 0px 15px 0px 0px; - } + margin: 0px 15px 0px 58%; + padding: 0px 15px 0px 0px; +} #input_box h2 { - color: #999; - - } + color: #999; + +} #input_box ul {list-style-type: circle; font-size: 0.9em;} .show_from_input, .due_input, .project_input, .context_input { - float:left; + float:left; } .box { - float: left; - width: 20px; - } + float: left; + width: 20px; +} div.item-container { - padding: 2px 0px; - line-height:20px; - clear: both; - } - + padding: 2px 0px; + line-height:20px; + clear: both; +} + +a.recurring_icon { + vertical-align: middle; + background-color: transparent; +} + a.icon { - float: left; - vertical-align: middle; - background-color: transparent; - } - + float: left; + vertical-align: middle; + background-color: transparent; +} + input.item-checkbox { - float: left; - margin-left: 10px; - vertical-align: middle; - } + float: left; + margin-left: 10px; + vertical-align: middle; +} .description { - margin-left: 85px; - position:relative - } + margin-left: 85px; + position:relative +} .stale_l1, .stale_l2, .stale_l3 { - margin-left: 82px; - padding-left: 3px; - } + margin-left: 82px; + padding-left: 3px; +} .stale_l1 { - background: #ffC; - } - + background: #ffC; +} + .tools { - margin-left: 25px; - width: 40px; - border-top: 1px solid #999; - } - + margin-left: 25px; + width: 40px; + border-top: 1px solid #999; +} + #footer { - clear: both; - font-size: 85%; - text-align: center; - color: #999; - margin: 20px 20px 5px 20px; - padding: 0px; - } - + clear: both; + font-size: 85%; + text-align: center; + color: #999; + margin: 20px 20px 5px 20px; + padding: 0px; +} + /* The notes which may be attached to an item */ .todo_notes { - margin: 5px; - padding: 3px; - border: 1px solid #F5ED59; - background: #FAF6AE; - color: #666666; - } - + margin: 5px; + padding: 3px; + border: 1px solid #F5ED59; + background: #FAF6AE; + color: #666666; +} + .todo_notes p, .todo_notes li { - padding: 1px; - margin: 0px; - font-size: 12px; - } - + padding: 1px; + margin: 0px; + font-size: 12px; +} + .todo_notes ul, .note_wrapper ul { - list-style-type: disc; - margin-left:20px; + list-style-type: disc; + margin-left:20px; } .todo_notes ol { - list-style-type: decimal; - margin-left:20px; + list-style-type: decimal; + margin-left:20px; } /* The notes for the project */ div.note_wrapper { - margin: 3px; - padding: 2px; - } + margin: 3px; + padding: 2px; +} div.note_wrapper p { - display: inline; - } - + display: inline; +} + div.note_footer { - border-top: 1px solid #999; - padding-top: 3px; - font-style: italic; - font-size: 0.9em; - color: #666; - } + border-top: 1px solid #999; + padding-top: 3px; + font-style: italic; + font-size: 0.9em; + color: #666; +} div.note_footer a, div.note_footer a:hover { - border-top: none; - padding-top: 0px; - vertical-align: middle; - background-color: transparent; - } + border-top: none; + padding-top: 0px; + vertical-align: middle; + background-color: transparent; +} div.add_note_link { - margin-top:12px; - float: right; - } + margin-top:12px; + float: right; +} div#project_status > div { - padding: 10px; - } + padding: 10px; +} #project_status span { - margin-right:5px; - background-color:white; + margin-right:5px; + background-color:white; } #project_status .active_state { - font-weight:bold; + font-weight:bold; } div#default_context > div{ - padding:10px; + padding:10px; } a.footer_link {color: #cc3334; font-style: normal;} a.footer_link:hover {color: #fff; background-color: #cc3334 !important;} @@ -393,192 +474,192 @@ a.footer_link:hover {color: #fff; background-color: #cc3334 !important;} /* Tag formatting */ span.tag { - font-size: 0.8em; - background-color: #CCE7FF; - color: #000; - padding: 1px; - margin-right: 2px; + font-size: 0.8em; + background-color: #CCE7FF; + color: #000; + padding: 1px; + margin-right: 2px; } span.tag a, span.tag a:link, span.tag a:active, span.tag a:visited { - color: #000; + color: #000; } span.tag a:hover { - background-color: #99CCFF; - color: #333; + background-color: #99CCFF; + color: #333; } /* Flash box styling */ div#message_holder { - position: absolute; - z-index: 100; - left: 60%; - top: 30px; - right: 0px; - margin: 0px; + position: absolute; + z-index: 100; + left: 60%; + top: 30px; + right: 0px; + margin: 0px; } h4.alert { - font-size: 1em; - margin: 0px; - padding: 5px; - text-align: center; + font-size: 1em; + margin: 0px; + padding: 5px; + text-align: center; } h4.warning { - border: 1px solid #ED2E38; - background-color: #F6979C; - color: #000; + border: 1px solid #ED2E38; + background-color: #F6979C; + color: #000; } h4.error { - color:#fff; - background:#c00; + color:#fff; + background:#c00; } h4.notice { - border: 1px solid #007E00; - background-color: #c2ffc2; - color: #007E00; + border: 1px solid #007E00; + background-color: #c2ffc2; + color: #007E00; } /* *****/ .project_completed { - border: 1px solid #007E00; - background-color: #c2ffc2; - padding: 5px; - color: #007E00; - text-align: center; + border: 1px solid #007E00; + background-color: #c2ffc2; + padding: 5px; + color: #007E00; + text-align: center; } - + /* Draw attention to some text - Same format as traffic lights */ +Same format as traffic lights */ .red { - color: #fff; - background: #f00; - padding: 1px; - font-size: 85%; - } - + color: #fff; + background: #f00; + padding: 1px; + font-size: 85%; +} + .amber { - color: #fff; - background: #ff6600; - padding: 1px; - font-size: 85%; - } - + color: #fff; + background: #ff6600; + padding: 1px; + font-size: 85%; +} + .orange { - color: #fff; - background: #FFA500; - padding: 1px; - font-size: 85%; - } + color: #fff; + background: #FFA500; + padding: 1px; + font-size: 85%; +} .green { - color: #fff; - background: #33cc00; - padding: 1px; - font-size: 85%; - } - + color: #fff; + background: #33cc00; + padding: 1px; + font-size: 85%; +} + .grey { - color: #fff; - background: #999; - padding: 2px; - font-size: 85%; - } - + color: #fff; + background: #999; + padding: 2px; + font-size: 85%; +} + .info { - color: #fff; - background: #CCC; - border: 1px solid #999; - padding: 5px; - text-align: center; - } - + color: #fff; + background: #CCC; + border: 1px solid #999; + padding: 5px; + text-align: center; +} + .highlight { - background: #ffC; - padding: 2px; + background: #ffC; + padding: 2px; } /* Backgrounds marking out 'staleness' of a task based on age of creation date - The colour of the background gets progressively yellower with age */ - +The colour of the background gets progressively yellower with age */ + .stale_l1 { - background: #ffC; - } - + background: #ffC; +} + .stale_l2 { - background: #ff6; - } + background: #ff6; +} .stale_l3 { - background: #ff0; - } + background: #ff0; +} /* Shows the number of undone next action */ .badge { - color: #fff; - background: #f00; - padding: 3px 5px; - font-size: 12pt; - margin: 10px 10px 0px 0px; - height:26px; - } - + color: #fff; + background: #f00; + padding: 3px 5px; + font-size: 12pt; + margin: 10px 10px 0px 0px; + height:26px; +} + ul { - list-style-type: none; - } + list-style-type: none; +} #sidebar h3 { - margin-top:15px; - margin-bottom:5px; + margin-top:15px; + margin-bottom:5px; } - + #sidebar ul { - margin-left: auto; - list-style-position: inside; - } - + margin-left: auto; + list-style-position: inside; +} + li { - font-size: 1.1em; - padding: 3px 0px; - } + font-size: 1.1em; + padding: 3px 0px; +} #sidebar li { - padding: auto; - } + padding: auto; +} #sidebar .integrations-link { - margin-top:10px; - padding-top:10px; - font-size: 0.8em; + margin-top:10px; + padding-top:10px; + font-size: 0.8em; } .sortable_row { - background: #fff; - _background: transparent; - padding: 4px 4px 4px 8px; - margin: 2px 2px; - border: 1px solid #ccc; - } + background: #fff; + _background: transparent; + padding: 4px 4px 4px 8px; + margin: 2px 2px; + border: 1px solid #ccc; +} .edit-form { - background: #ccc; - padding: 0 10px; - border-top: 1px solid #999; - border-bottom: 1px solid #999; - position:relative; - } + background: #ccc; + padding: 0 10px; + border-top: 1px solid #999; + border-bottom: 1px solid #999; + position:relative; +} /* Right align labels in forms */ .label { - text-align: right; - } - + text-align: right; +} + input { - vertical-align: middle; + vertical-align: middle; } /* Positioning the 'cells' in the list */ @@ -587,47 +668,47 @@ img.position, a:hover img.position { text-align: left; vertical-align: middle; background-color: transparent; - } - +} + .data { text-align: left; margin-left: 20px; float: left; - } - +} + div.buttons, div.buttons a, div.buttons a:hover { text-align: right; margin-right: 0px; vertical-align: middle; background-color: transparent; - } +} div#list-active-projects, div#list-hidden-projects, div#list-completed-projects, div#list-contexts, div#projects-empty-nd { clear:right; - border: 1px solid #999; - } + border: 1px solid #999; +} .project-state-group h2 { - margin:20px 0px 8px 13px; + margin:20px 0px 8px 13px; } .search-result-group h2 { - margin:20px 0px 8px 13px + margin:20px 0px 8px 13px } div.alpha_sort { - margin-top:-20px; - float:right; + margin-top:-20px; + float:right; } - + .container td { border: none; padding-bottom: 5px; - } +} .container form { border: none; - } +} div.project_description { background: #eee; @@ -640,48 +721,52 @@ div.project_description { font-size: 12px; font-weight: normal; /* Uncomment line below if you want the description to have - shadowed text */ + shadowed text */ /* text-shadow: rgba(0,0,0,.4) 0px 2px 5px; */ - } -#project-next-prev { - text-align:right; } - +#project-next-prev { + text-align:right; +} + /* Form elements */ form { border: 1px solid #CCC; padding: 10px; margin: 0px; - } +} .inline-form { border: none; padding: 3px; - } +} .inline-form table { padding-right: 3px; - } +} /* expand form contents to fill the whole form */ .inline-form table, .inline-form textarea#item_notes, .inline-form input#item_description { width: 100%; - } +} /* shrink the label/left column as small as necessary */ .inline-form table td.label { width: 13ex; - } -#todo_new_action_container, #project_new_project_container, #context_new_container { - background: #ddd; - width: 270px; - padding: 5px 10px; - background-color: #000; - filter: alpha(opacity=75); - -moz-opacity: .75; - opacity: .75; - color: #eee; +} +#todo_new_action_container, #project_new_project_container, #context_new_container, #recurring_new_container { + background: #ddd; + width: 270px; + padding: 5px 10px; + background-color: #000; + filter: alpha(opacity=75); + -moz-opacity: .75; + opacity: .75; + color: #eee; +} + +#recurring_new_container img { + vertical-align: middle; } #project_new_project_filler { @@ -693,7 +778,7 @@ form { #project_new_project_container input, #project_new_project_container textarea, #context_new_container input { - width: 100%; + width: 100%; } input#go_to_project, input#context_hide { @@ -701,47 +786,47 @@ input#go_to_project, input#context_hide { } #todo_new_action_container .show_from_input, #todo_new_action_container .due_input { - width: 45%; + width: 45%; } #todo_new_action_container .show_from_input { - float: right; + float: right; } #todo-form-new-action .submit_box, #project_form .submit_box, #context_form .submit_box { - height: 25px; - padding: 5px 0; - text-align: center; - clear: right; + height: 25px; + padding: 5px 0; + text-align: center; + clear: right; } .edit_todo_form .submit_box { - height: 25px; - padding: 5px 0; - text-align: center; - clear: right; + height: 25px; + padding: 5px 0; + text-align: center; + clear: right; } .edit_todo_form input, .edit_todo_form textarea { - width:100%; + width:100%; } .edit_todo_form .tag_list_label { - clear:both; + clear:both; } .edit_todo_form .due_input, .edit_todo_form .show_from_input, .edit_todo_form .project_input, .edit_todo_form .context_input { - width:48%; + width:48%; } .edit_todo_form .show_from_input, .edit_todo_form .context_input { - float: right; + float: right; } .edit_todo_form .submit_box input { - width:120px; + width:120px; } .hide_form { - text-align:right; + text-align:right; } #todo-form-new-action label, .edit_todo_form label { display: block; @@ -749,20 +834,20 @@ input#go_to_project, input#context_hide { } form.button-to { - border: none; - padding: 0px; - margin: 0px; + border: none; + padding: 0px; + margin: 0px; } label { font-weight: bold; padding: 0px 0px; - } - +} + input, select, textarea { margin: 0px 0px 5px 0px; - } - +} + .feed { font-family: verdana, sans-serif; font-size: 10px; @@ -774,13 +859,13 @@ input, select, textarea { border-color: #FC9 #630 #330 #F96; padding:0px 3px 0px 3px; margin:0px; - } - +} + /* Classes for Drag and Drop */ .position { float: left; - margin-top:2px; - } + margin-top:2px; +} .handle { color: #fff; background: #000; @@ -790,180 +875,180 @@ input, select, textarea { } div.message { - margin: 5px 0px; - background: #FAF4B5; - padding: 2px; + margin: 5px 0px; + background: #FAF4B5; + padding: 2px; } .message p { - margin: 0px; - padding: 0px; - text-align: center; - font-size: 1em; - color: #666; + margin: 0px; + padding: 0px; + text-align: center; + font-size: 1em; + color: #666; } /* Error message styles */ .fieldWithErrors { - padding: 2px; - background-color: red; - display: table; + padding: 2px; + background-color: red; + display: table; } #errorExplanation { - border: 2px solid #ff0000; - padding: 7px; - padding-bottom: 12px; - margin: 10px auto 20px auto; - background-color: #f0f0f0; + border: 2px solid #ff0000; + padding: 7px; + padding-bottom: 12px; + margin: 10px auto 20px auto; + background-color: #f0f0f0; } #errorExplanation h2 { - text-align: left; - font-weight: bold; - padding: 5px 5px 5px 15px; - font-size: 12px; - margin: -7px; - background-color: #c00; - color: #fff; + text-align: left; + font-weight: bold; + padding: 5px 5px 5px 15px; + font-size: 12px; + margin: -7px; + background-color: #c00; + color: #fff; } #errorExplanation > p { - color: #333; - margin-top: 5px; - margin-bottom: 0; - padding: 5px; + color: #333; + margin-top: 5px; + margin-bottom: 0; + padding: 5px; } #errorExplanation ul li { - font-size: 1em; - list-style-type: disc; - list-style-position: inside; - margin-left:7px; - color: #333; + font-size: 1em; + list-style-type: disc; + list-style-position: inside; + margin-left:7px; + color: #333; } ul#prefs {list-style-type: disc; margin-left: 15px;} #token_area, #authentication_area { - text-align:center; - margin-top:20px; - margin-bottom:10px; + text-align:center; + margin-top:20px; + margin-bottom:10px; } #token_area .description{ - font-weight:bold; + font-weight:bold; } #token_area form { - width:100%; - text-align:center; + width:100%; + text-align:center; } .prefscontainer .actions { - text-align:center; - margin-bottom:20px; + text-align:center; + margin-bottom:20px; } .authtype_container .actions { - margin-top:20px; - margin-bottom:20px; + margin-top:20px; + margin-bottom:20px; } #feedlegend { - padding: 2px; - border: 1px solid #CCC; - background-color: #D2D3D6; - color: #666; - padding: 5px 20px; - text-align: left; + padding: 2px; + border: 1px solid #CCC; + background-color: #D2D3D6; + color: #666; + padding: 5px 20px; + text-align: left; } #feedlegend h3, #feedlegend dl, #feedlegend dt, #feedlegend dd { - display: inline; + display: inline; } #feedlegend dt { - margin-left: 15px; + margin-left: 15px; } #feedlegend dd { - margin-left: 3px; + margin-left: 3px; } #feedlegend p { - margin-bottom: 0px; + margin-bottom: 0px; } #feeds img.rss-icon { - margin-bottom: -4px; + margin-bottom: -4px; } #feeds li { - font-size:13px; - font-family: "Lucida Grande", Verdana, Geneva, Arial, sans-serif; + font-size:13px; + font-family: "Lucida Grande", Verdana, Geneva, Arial, sans-serif; } #feeds li h4 { - margin-top: 12px; - margin-bottom: 4px; - font-weight: normal; - font-style:oblique; + margin-top: 12px; + margin-bottom: 4px; + font-weight: normal; + font-style:oblique; } input.open_id { - background: url(../images/open-id-login-bg.gif) no-repeat; - background-color: #fff; - background-position: 0 50%; - color: #000; - padding-left: 18px; - width:182px; + background: url(../images/open-id-login-bg.gif) no-repeat; + background-color: #fff; + background-position: 0 50%; + color: #000; + padding-left: 18px; + width:182px; } div.page_name_auto_complete { - width: 100%; - background: #fff; - display: inline; - z-index: 100; + width: 100%; + background: #fff; + display: inline; + z-index: 100; } div.page_name_auto_complete ul { - border: 1px solid #888; - margin: 0; - padding: 0; - width: 100%; - list-style-type: none; + border: 1px solid #888; + margin: 0; + padding: 0; + width: 100%; + list-style-type: none; } div.page_name_auto_complete ul li { - margin: 0; - padding: 2px; - font-weight:bold; - list-style-type: none; - color: #000; + margin: 0; + padding: 2px; + font-weight:bold; + list-style-type: none; + color: #000; } div.page_name_auto_complete ul li.selected { - background-color: #ffb; + background-color: #ffb; } div.page_name_auto_complete ul strong.highlight { - color: #800; - margin: 0; - padding: 0; + color: #800; + margin: 0; + padding: 0; } table.next_actions td { - padding:5px 3px 2px 0px; + padding:5px 3px 2px 0px; } table.users_table { - width: 100%; - text-align: center; - border: 1px solid #666; - background-color: #fff; - border-spacing: 0px; + width: 100%; + text-align: center; + border: 1px solid #666; + background-color: #fff; + border-spacing: 0px; } .users_table th {color: #fff; background-color: #000;} .users_table td {border: none;} table.export_table { - border: 1px solid #666; - background-color: #fff; - border-spacing: 0px; + border: 1px solid #666; + background-color: #fff; + border-spacing: 0px; } .export_table th {color: #fff; background-color: #000;} .export_table td {border: 1px; padding: 5px 0px 5px 5px;} /* Submit button styling from ParticleTree - http://particletree.com/features/rediscovering-the-button-element/ */ +http://particletree.com/features/rediscovering-the-button-element/ */ .widgets a, .widgets button{ display:block; @@ -973,7 +1058,7 @@ table.export_table { border:1px solid #dedede; border-top:1px solid #eee; border-left:1px solid #eee; - + font-family:"Lucida Grande", Verdana, Geneva, Arial, sans-serif; font-size:80%; line-height:100%; @@ -1047,61 +1132,61 @@ button.positive, .widgets a.positive{ color:#fff; } .tracks__waiting { - background-image:url('../images/waiting.gif'); - background-repeat:no-repeat; - background-position:center center; - background-color:white; + background-image:url('../images/waiting.gif'); + background-repeat:no-repeat; + background-position:center center; + background-color:white; } .bigWaiting { - background-image:url('../images/bigWaiting.gif'); - background-repeat:no-repeat; - background-position:center 20%; - background-color:white; + background-image:url('../images/bigWaiting.gif'); + background-repeat:no-repeat; + background-position:center 20%; + background-color:white; } .blackWaiting { - background-image:url('../images/blackWaiting.gif'); - background-repeat:no-repeat; - background-position:center center; - background-color:black; + background-image:url('../images/blackWaiting.gif'); + background-repeat:no-repeat; + background-position:center center; + background-color:black; } .bigBlackWaiting { - background-image:url('../images/bigBlackWaiting.gif'); - background-repeat:no-repeat; - background-position:center center; - background-color:black; + background-image:url('../images/bigBlackWaiting.gif'); + background-repeat:no-repeat; + background-position:center center; + background-color:black; } .stats_content .open-flash-chart, .stats_content .stats_module { - float: left; - width: 450px; - margin-right:20px; - padding-bottom:20px; + float: left; + width: 450px; + margin-right:20px; + padding-bottom:20px; } .stats_content h2 { - clear:both; - margin-top:15px; - margin-bottom:15px; + clear:both; + margin-top:15px; + margin-bottom:15px; } body.integrations h2 { - margin-top:40px; - padding-top:20px; - margin-bottom:10px; - border-top:1px solid #ccc; + margin-top:40px; + padding-top:20px; + margin-bottom:10px; + border-top:1px solid #ccc; } body.integrations p, body.integrations li { - font-size:1.0em; + font-size:1.0em; } body.integrations li { - list-style-type: disc; - list-style-position: inside; - margin-left:30px; + list-style-type: disc; + list-style-position: inside; + margin-left:30px; } body.integrations textarea { - margin:10px; - padding:3px; - width:80%; - background-color:#ddd; + margin:10px; + padding:3px; + width:80%; + background-color:#ddd; } \ No newline at end of file diff --git a/test/fixtures/recurring_todos.yml b/test/fixtures/recurring_todos.yml new file mode 100644 index 00000000..81c3e670 --- /dev/null +++ b/test/fixtures/recurring_todos.yml @@ -0,0 +1,129 @@ +1: + id: 1 + user_id: 1 + context_id: 1 + project_id: 2 + description: Call Bill Gates every day + notes: ~ + state: active + start_from: ~ + ends_on: no_end_date + end_date: ~ + number_of_occurences: ~ + target: due_date + show_from_delta: ~ + recurring_period: daily + recurrence_selector: ~ + every_other1: 1 + every_other2: ~ + every_other3: ~ + every_day: ~ + only_work_days: false + every_count: ~ + weekday: ~ + created_at: <%= last_week %> + completed_at: ~ + +2: + id: 2 + user_id: 1 + context_id: 1 + project_id: 2 + description: Call Bill Gates every workday + notes: ~ + state: active + start_from: ~ + ends_on: no_end_date + end_date: ~ + number_of_occurences: ~ + target: due_date + show_from_delta: ~ + recurring_period: daily + recurrence_selector: ~ + every_other1: ~ + every_other2: ~ + every_other3: ~ + every_day: ~ + only_work_days: true + every_count: ~ + weekday: ~ + created_at: <%= last_week %> + completed_at: ~ + +3: + id: 3 + user_id: 1 + context_id: 1 + project_id: 2 + description: Call Bill Gates every week + notes: ~ + state: active + start_from: ~ + ends_on: no_end_date + end_date: ~ + number_of_occurences: ~ + target: due_date + show_from_delta: ~ + recurring_period: weekly + recurrence_selector: ~ + every_other1: 2 + every_other2: ~ + every_other3: ~ + every_day: smtwtfs + only_work_days: false + every_count: ~ + weekday: ~ + created_at: <%= last_week %> + completed_at: ~ + +4: + id: 4 + user_id: 1 + context_id: 1 + project_id: 2 + description: Check with Bill every last friday of the month + notes: ~ + state: active + start_from: ~ + ends_on: no_end_date + end_date: ~ + number_of_occurences: ~ + target: due_date + show_from_delta: 5 + recurring_period: monthly + recurrence_selector: 1 + every_other1: 1 + every_other2: 2 + every_other3: 5 + every_day: ~ + only_work_days: false + every_count: 5 + weekday: ~ + created_at: <%= last_week %> + completed_at: ~ + +5: + id: 5 + user_id: 1 + context_id: 1 + project_id: 2 + description: Congratulate Reinier on his birthday + notes: ~ + state: active + start_from: ~ + ends_on: no_end_date + end_date: ~ + number_of_occurences: ~ + target: due_date + show_from_delta: 5 + recurring_period: yearly + recurrence_selector: 0 + every_other1: 8 + every_other2: 6 + every_other3: ~ + every_day: ~ + only_work_days: false + every_count: ~ + weekday: ~ + created_at: <%= last_week %> + completed_at: ~ \ No newline at end of file diff --git a/test/functional/recurring_todos_controller_test.rb b/test/functional/recurring_todos_controller_test.rb new file mode 100644 index 00000000..1d0d174e --- /dev/null +++ b/test/functional/recurring_todos_controller_test.rb @@ -0,0 +1,22 @@ +require File.dirname(__FILE__) + '/../test_helper' + +class RecurringTodosControllerTest < ActionController::TestCase + fixtures :users, :preferences, :projects, :contexts, :todos, :tags, :taggings, :recurring_todos + + def setup + @controller = RecurringTodosController.new + @request, @response = ActionController::TestRequest.new, ActionController::TestResponse.new + end + + def test_get_index_when_not_logged_in + get :index + assert_redirected_to :controller => 'login', :action => 'login' + end + + def test_destroy_recurring_todo + login_as(:admin_user) + xhr :post, :destroy, :id => 1, :_source_view => 'todo' + assert_rjs :page, "recurring_todo_1", :remove + end + +end diff --git a/test/unit/recurring_todo_test.rb b/test/unit/recurring_todo_test.rb new file mode 100644 index 00000000..155b9112 --- /dev/null +++ b/test/unit/recurring_todo_test.rb @@ -0,0 +1,249 @@ +require File.dirname(__FILE__) + '/../test_helper' + +class RecurringTodoTest < Test::Rails::TestCase + fixtures :todos, :users, :contexts, :preferences, :tags, :taggings, :recurring_todos + + def setup + @every_day = RecurringTodo.find(1).reload + @every_workday = RecurringTodo.find(2).reload + @weekly_every_day = RecurringTodo.find(3).reload + @monthly_every_last_friday = RecurringTodo.find(4).reload + @yearly = RecurringTodo.find(5).reload + + @today = Time.now.utc + @tomorrow = @today + 1.day + @in_three_days = Time.now.utc + 3.days + @in_four_days = @in_three_days + 1.day # need a day after start_from + + @friday = Time.utc(2008,6,6) + @saturday = Time.utc(2008,6,7) + @sunday = Time.utc(2008,6,8) # june 8, 2008 was a sunday + @monday = Time.utc(2008,6,9) + @tuesday = Time.utc(2008,6,10) + @wednesday = Time.utc(2008,6,11) + @thursday = Time.utc(2008,6,12) + end + + def test_pattern_text + assert_equal "every day", @every_day.recurrence_pattern + assert_equal "on work days", @every_workday.recurrence_pattern + assert_equal "every last Friday of every 2 months", @monthly_every_last_friday.recurrence_pattern + assert_equal "every year on June 8", @yearly.recurrence_pattern + end + + def test_daily_every_day + # every_day should return todays date if there was no previous date + due_date = @every_day.get_due_date(nil) + # use to_s in compare, because milisec could be different + assert_equal @today.to_s, due_date.to_s + + # when the last todo was completed today, the next todo is due tomorrow + due_date =@every_day.get_due_date(@today) + assert_equal @tomorrow, due_date + + # every_day should return start_day if it is in the future + @every_day.start_from = @in_three_days + due_date = @every_day.get_due_date(nil) + assert_equal @in_three_days, due_date + + # if we give a date in the future for the previous todo, the next to do + # should be based on that future date. + due_date = @every_day.get_due_date(@in_four_days) + assert_equal @in_four_days+1.day, due_date + + # do something every 14 days + @every_day.every_other1=14 + due_date = @every_day.get_due_date(@today) + assert_equal @today+14.days, due_date + end + + def test_daily_work_days + assert_equal @monday, @every_workday.get_due_date(@friday) + assert_equal @monday, @every_workday.get_due_date(@saturday) + assert_equal @monday, @every_workday.get_due_date(@sunday) + assert_equal @tuesday, @every_workday.get_due_date(@monday) + end + + def test_show_from_date + # assume that target due_date works fine, i.e. don't do the same tests over + + @every_day.target='show_from_date' + # when recurrence is targeted on show_from, due date shoult remain nil + assert_equal nil, @every_day.get_due_date(nil) + assert_equal nil, @every_day.get_due_date(@today-3.days) + + # check show from get the next day + assert_equal @today, @every_day.get_show_from_date(@today-1.days) + + @every_day.target='due_date' + # when target on due_date, show_from is relative to due date unless delta=0 + assert_equal nil, @every_day.get_show_from_date(@today-1.days) + + @every_day.show_from_delta=10 + assert_equal @today, @every_day.get_show_from_date(@today+9.days) #today+1+9-10 + + # TODO: show_from has no use case for daily pattern. Need to test on + # weekly/monthly/yearly + end + + def test_end_date_on_recurring_todo + assert_equal true, @every_day.has_next_todo(@in_three_days) + assert_equal true, @every_day.has_next_todo(@in_four_days) + @every_day.end_date = @in_four_days + assert_equal false, @every_day.has_next_todo(@in_four_days) + end + + def test_weekly_every_day_setters + @weekly_every_day.every_day = ' ' + + @weekly_every_day.weekly_return_sunday=('s') + assert_equal 's ', @weekly_every_day.every_day + @weekly_every_day.weekly_return_monday=('m') + assert_equal 'sm ', @weekly_every_day.every_day + @weekly_every_day.weekly_return_tuesday=('t') + assert_equal 'smt ', @weekly_every_day.every_day + @weekly_every_day.weekly_return_wednesday=('w') + assert_equal 'smtw ', @weekly_every_day.every_day + @weekly_every_day.weekly_return_thursday=('t') + assert_equal 'smtwt ', @weekly_every_day.every_day + @weekly_every_day.weekly_return_friday=('f') + assert_equal 'smtwtf ', @weekly_every_day.every_day + @weekly_every_day.weekly_return_saturday=('s') + assert_equal 'smtwtfs', @weekly_every_day.every_day + + # test remove + @weekly_every_day.weekly_return_wednesday=(' ') + assert_equal 'smt tfs', @weekly_every_day.every_day + end + + def test_weekly_pattern + assert_equal true, @weekly_every_day.has_next_todo(nil) + + due_date = @weekly_every_day.get_due_date(@sunday) + assert_equal @monday, due_date + + # saturday is last day in week, so the next date should be sunday + n_weeks + due_date = @weekly_every_day.get_due_date(@saturday) + assert_equal @sunday + 2.weeks, due_date + + # remove tuesday and wednesday + @weekly_every_day.weekly_return_tuesday=(' ') + @weekly_every_day.weekly_return_wednesday=(' ') + assert_equal 'sm tfs', @weekly_every_day.every_day + due_date = @weekly_every_day.get_due_date(@monday) + assert_equal @thursday, due_date + + @weekly_every_day.every_other1 = 1 + @weekly_every_day.every_day = ' tw ' + due_date = @weekly_every_day.get_due_date(@tuesday) + assert_equal @wednesday, due_date + due_date = @weekly_every_day.get_due_date(@wednesday) + assert_equal @tuesday+1.week, due_date + end + + def test_monthly_pattern + due_date = @monthly_every_last_friday.get_due_date(@sunday) + assert_equal Time.utc(2008,6,27), due_date + + friday_is_last_day_of_month = Time.utc(2008,10,31) + due_date = @monthly_every_last_friday.get_due_date(friday_is_last_day_of_month ) + assert_equal friday_is_last_day_of_month , due_date + + @monthly_every_third_friday = @monthly_every_last_friday + @monthly_every_third_friday.every_other3=3 #third + due_date = @monthly_every_last_friday.get_due_date(@sunday) # june 8th 2008 + assert_equal Time.utc(2008, 6, 20), due_date + # set date past third friday of this month + due_date = @monthly_every_last_friday.get_due_date(Time.utc(2008,6,21)) # june 21th 2008 + assert_equal Time.utc(2008, 8, 15), due_date # every 2 months, so aug + + @monthly = @monthly_every_last_friday + @monthly.recurrence_selector=0 + @monthly.every_other1 = 8 # every 8th day of the month + @monthly.every_other2 = 2 # every 2 months + + due_date = @monthly.get_due_date(@saturday) # june 7th + assert_equal @sunday, due_date # june 8th + + due_date = @monthly.get_due_date(@sunday) # june 8th + assert_equal Time.utc(2008,8,8), due_date # aug 8th + + end + + def test_yearly_pattern + # beginning of same year + due_date = @yearly.get_due_date(Time.utc(2008,2,10)) # feb 10th + assert_equal @sunday, due_date # june 8th + # same month, previous date + due_date = @yearly.get_due_date(@saturday) # june 7th + show_from_date = @yearly.get_show_from_date(@saturday) # june 7th + assert_equal @sunday, due_date # june 8th + assert_equal @sunday-5.days, show_from_date + # same month, day after + due_date = @yearly.get_due_date(@monday) # june 9th + assert_equal Time.utc(2009,6,8), due_date # june 8th next year + + @yearly.recurrence_selector = 1 + @yearly.every_other3 = 2 # second + @yearly.every_count = 3 # wednesday + # beginning of same year + due_date = @yearly.get_due_date(Time.utc(2008,2,10)) # feb 10th + assert_equal Time.utc(2008,6,11), due_date # june 11th + # same month, before second wednesday + due_date = @yearly.get_due_date(@saturday) # june 7th + assert_equal Time.utc(2008,6,11), due_date # june 11th + # same month, after second wednesday + due_date = @yearly.get_due_date(Time.utc(2008,6,12)) # june 7th + assert_equal Time.utc(2009,6,10), due_date # june 10th + + # test handling of nil + due_date1 = @yearly.get_due_date(nil) + due_date2 = @yearly.get_due_date(Time.now.utc + 1.day) + assert_equal due_date1, due_date2 + + this_year = Time.now.utc.year + @yearly.start_from = Time.utc(this_year+1,6,12) + due_date = @yearly.get_due_date(nil) + assert_equal due_date.year, this_year+2 + end + + def test_toggle_completion + t = @yearly + assert_equal :active, t.current_state + t.toggle_completion! + assert_equal :completed, t.current_state + t.toggle_completion! + assert_equal :active, t.current_state + end + + def test_starred + @yearly.tag_with("1, 2, starred", User.find(@yearly.user_id)) + @yearly.tags.reload + + assert_equal true, @yearly.starred? + assert_equal false, @weekly_every_day.starred? + + @yearly.toggle_star! + assert_equal false, @yearly.starred? + @yearly.toggle_star! + assert_equal true, @yearly.starred? + end + + def test_occurence_count + @every_day.number_of_occurences = 2 + assert_equal true, @every_day.has_next_todo(@in_three_days) + @every_day.inc_occurences + assert_equal true, @every_day.has_next_todo(@in_three_days) + @every_day.inc_occurences + assert_equal false, @every_day.has_next_todo(@in_three_days) + + # after completion, when you reactivate the recurring todo, the occurences + # count should be reset + assert_equal 2, @every_day.occurences_count + @every_day.toggle_completion! + @every_day.toggle_completion! + assert_equal true, @every_day.has_next_todo(@in_three_days) + assert_equal 0, @every_day.occurences_count + end + +end